feat: add asynchronous LSP plugin diagnostics and definitions
This commit is contained in:
Generated
+830
-9
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -6,7 +6,7 @@ edition = "2024"
|
||||
[dependencies]
|
||||
crossterm = "0.29"
|
||||
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_json = "1"
|
||||
tree-sitter = "0.27.1"
|
||||
|
||||
@@ -200,18 +200,19 @@ Leader bindings require Normal/Visual and the target's existing mode eligibility
|
||||
|
||||
## Module seams
|
||||
|
||||
One crate, seven modules, no workspace or terminal-coordinate types in the editing backend:
|
||||
One crate, eight modules, no workspace or terminal-coordinate types in the editing backend:
|
||||
|
||||
- `editor`: owns buffer lifecycle and per-buffer text, saved state, primary/selection set, modes, search, and undo/redo; session-wide clipboard policy, independent registers and owned-system fragment metadata, semantic native repeat, action metadata, command-palette and file-picker focus/dispatch, key notation, leader/count prefixes and binding resolution, modal paste policy, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected.
|
||||
- `files`: owns the gitignore-aware directory/file catalog, authoritative OS paths, compact tree projection, query filtering, browse/search focus, expansion/selection restoration, navigation, and scan/error state shared by floating and docked presentations.
|
||||
- `clipboard`: owns native plain-text provider selection and bounded subprocess I/O; it never computes selections or editing offsets.
|
||||
- `syntax`: owns Tree-sitter grammar detection, per-buffer incremental trees, bundled highlight/local/injection queries, and semantic byte spans; it has no terminal color/geometry or editing dependency.
|
||||
- `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.
|
||||
- `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` and `styled_text` capabilities. It maps semantic tokens to terminal colors and 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.
|
||||
- `session`: `EditorSession` owns the editor and its Ruby hosts together. It loads configuration once, registers eligible plugins before installing shared bindings, tracks document open/change/save/close events, polls asynchronous results, validates versioned diagnostics/navigation, and retires host and command ownership on invocation or effect-validation failure.
|
||||
- `tui`: translates terminal events to session input, renders read-only active-buffer state, keeps frontend-owned viewports and syntax caches by stable buffer ID, and advertises `text`, `styled_text`, and `diagnostics` capabilities. It maps semantic tokens and diagnostics to terminal styling and has no server/protocol logic or paste acceptance policy.
|
||||
- `ruby`: evaluates configuration and runs isolated plugin hosts. Protocol, deadlines, and process cleanup stay inside this module.
|
||||
- The executable selects paths and frontend capabilities, opens an editor session, and launches the TUI.
|
||||
|
||||
Frontends drive `EditorSession::handle` or `execute` 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 `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.
|
||||
@@ -219,6 +220,7 @@ 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 `buffer_id()`, `buffer_count()`, `has_buffer(id)`, and the ascending-ID `buffers()` iterator of borrowed path/dirty/active metadata. Existing text, cursor, selection, search, and mode methods describe only the active buffer.
|
||||
Frontends inspect `file_picker()` for query, presentation, view, explorer-local INSERT/filter subfocus, selection, and errors, and iterate borrowed `FileRow` metadata (actual path, display label, directory flag, visible depth, expansion). Dock side is a logical preference. They allocate rectangles, add folder suffixes/colors, and render; hierarchy, filtering, parent/child navigation, focus transitions, and selected-file opening remain backend-owned.
|
||||
Frontends inspect `diagnostics()` and `diagnostic_at_cursor()` for validated active-buffer annotations. Diagnostics are owned by a plugin and buffer revision; text changes and owner 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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -240,6 +242,28 @@ Built-in Ex commands register as the protected `core` feature in the same comman
|
||||
|
||||
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.
|
||||
|
||||
### Optional LSP plugin
|
||||
|
||||
LSP is implemented in `examples/plugins/lsp.rb`, not the editing core. It requires Ruby and an independently installed, trusted language server; it is not loaded by default. Enable it in your config (the plugin path is relative to that config):
|
||||
|
||||
```ruby
|
||||
config.plugin "plugins/lsp.rb", options: {
|
||||
servers: [
|
||||
{ language_id: "rust", extensions: ["rs"], command: ["rust-analyzer"] },
|
||||
{ language_id: "python", extensions: ["py"], command: ["pylsp"] }
|
||||
]
|
||||
}
|
||||
config.bind "leader d", "lsp_definition"
|
||||
```
|
||||
|
||||
Server commands are argv arrays, never shell commands. The first matching extension configuration wins. One server runs per configuration/project root, using the startup working directory as its workspace. The plugin initializes the server, honors its open/change/save capabilities, synchronizes in-memory text (including hidden dirty buffers), and sends close events. Servers requesting incremental synchronization receive whole-document range replacements. Server transport and requests run in plugin worker threads; the editor continues processing input and idle diagnostics.
|
||||
|
||||
`: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.
|
||||
|
||||
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.
|
||||
|
||||
This plugin currently provides synchronization, diagnostics, and go-to-definition—not completion, hover, rename, formatting, code actions, workspace edits, or semantic highlighting. Tree-sitter remains the syntax highlighter. Server configuration requests return `null` for defaults; custom server settings and workspace discovery are not implemented.
|
||||
|
||||
## 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.
|
||||
@@ -333,23 +357,33 @@ end
|
||||
|
||||
Run `:greet vamp`, or press `g` with the example configuration. `examples/plugins/greeting.rb` also provides `:upper` to uppercase the buffer.
|
||||
|
||||
Callbacks receive an immutable primary-only context snapshot: `editor.text`, `editor.row`, `editor.column`, `editor.mode`, 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.
|
||||
|
||||
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`; 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.
|
||||
|
||||
### 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.
|
||||
|
||||
JSON uses Ruby's standard library. Protocol Buffers is not installed or required; there is no frontend/network wire protocol yet. The frontend currently calls the Rust library directly.
|
||||
|
||||
## Current limits
|
||||
|
||||
- One 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. Optional LSP currently supports diagnostics and definitions only. 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.
|
||||
- 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.
|
||||
|
||||
## Verify
|
||||
@@ -364,3 +398,5 @@ 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.
|
||||
|
||||
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 through the optional Ruby plugin: fragmented/adjacent frames, initialization and synchronization capabilities, open/change/save/close, UTF-16 and encoded Unicode paths, dirty-buffer definitions, stale/superseded requests and diagnostics, unsupported server requests, crashes, malformed frames, unavailable servers, and surviving native editing. A real executable PTY smoke also exercised idle diagnostic redraw, active-server editing, definition navigation into a dirty loaded buffer, save/close notifications, and clean shutdown. No desktop clipboard or user files are touched.
|
||||
|
||||
@@ -13,6 +13,11 @@ Vamp.configure do |config|
|
||||
config.bind "ctrl+h", config.core.left, mode: config.modes.tree
|
||||
config.bind "ctrl+l", config.core.right, mode: config.modes.tree
|
||||
config.plugin "plugins/greeting.rb"
|
||||
# Optional LSP feature; install/configure a trusted language server first.
|
||||
# config.plugin "plugins/lsp.rb", options: {
|
||||
# servers: [{ language_id: "rust", extensions: ["rs"], command: ["rust-analyzer"] }]
|
||||
# }
|
||||
# config.bind "leader d", "lsp_definition"
|
||||
config.bind "g", "greet"
|
||||
config.bind "leader g", "greet"
|
||||
config.bind "ctrl+r", config.core.redo
|
||||
|
||||
@@ -0,0 +1,619 @@
|
||||
require "json"
|
||||
require "open3"
|
||||
require "uri"
|
||||
|
||||
# All server/protocol work stays in this optional plugin. Host callbacks only enqueue.
|
||||
module VampLsp
|
||||
MAX_BODY = 4 * 1024 * 1024
|
||||
MAX_HEADER = 16 * 1024
|
||||
QUEUE_SIZE = 128
|
||||
MAX_VERSION = (1 << 31) - 1
|
||||
|
||||
def self.clean(text)
|
||||
raise ArgumentError, "LSP message must be UTF-8 text" unless text.is_a?(String) && text.valid_encoding?
|
||||
text.gsub(/[[:cntrl:]]/, " ")
|
||||
end
|
||||
|
||||
def self.uri(path)
|
||||
"file://#{URI::DEFAULT_PARSER.escape(File.expand_path(path), /[^A-Za-z0-9\-._~\/]/)}"
|
||||
end
|
||||
|
||||
def self.path(uri)
|
||||
raise ArgumentError, "definition URI must be a String" unless uri.is_a?(String)
|
||||
parsed = URI.parse(uri)
|
||||
unless parsed.scheme == "file" && [nil, "", "localhost"].include?(parsed.host) &&
|
||||
parsed.userinfo.nil? && parsed.port.nil? && parsed.query.nil? && parsed.fragment.nil? &&
|
||||
parsed.path.start_with?("/") && !parsed.path.match?(/%(?![0-9a-fA-F]{2})/)
|
||||
raise ArgumentError, "unsupported local file URI: #{clean(uri)}"
|
||||
end
|
||||
path = URI::DEFAULT_PARSER.unescape(parsed.path).force_encoding(Encoding::UTF_8)
|
||||
raise ArgumentError, "invalid UTF-8 file URI" unless path.valid_encoding? && !path.match?(/[[:cntrl:]]/)
|
||||
File.expand_path(path)
|
||||
end
|
||||
|
||||
def self.identity(path)
|
||||
File.realpath(path)
|
||||
rescue SystemCallError
|
||||
File.expand_path(path)
|
||||
end
|
||||
|
||||
def self.lines(text)
|
||||
text.gsub(/\r\n?/, "\n").split("\n", -1)
|
||||
end
|
||||
|
||||
def self.integer(value, label)
|
||||
raise ArgumentError, "invalid LSP #{label}" unless value.is_a?(Integer) && value.between?(0, MAX_VERSION)
|
||||
value
|
||||
end
|
||||
|
||||
# Expand positions inside a surrogate pair or combining cluster to whole graphemes.
|
||||
def self.position(lines, value, round_up: false)
|
||||
raise ArgumentError, "invalid LSP position" unless value.is_a?(Hash)
|
||||
row = integer(value["line"], "line")
|
||||
units = integer(value["character"], "character")
|
||||
line = lines.fetch(row) { raise ArgumentError, "LSP line is outside the document" }
|
||||
offset = 0
|
||||
column = 0
|
||||
line.each_grapheme_cluster do |cluster|
|
||||
return { "row" => row, "column" => column } if units == offset
|
||||
width = cluster.encode(Encoding::UTF_16LE).bytesize / 2
|
||||
if units < offset + width
|
||||
return { "row" => row, "column" => column + (round_up ? 1 : 0) }
|
||||
end
|
||||
offset += width
|
||||
column += 1
|
||||
end
|
||||
# LSP specifies oversized character offsets as end-of-line.
|
||||
{ "row" => row, "column" => column }
|
||||
end
|
||||
|
||||
def self.range(lines, value)
|
||||
raise ArgumentError, "invalid LSP range" unless value.is_a?(Hash)
|
||||
first = value["start"]
|
||||
last = value["end"]
|
||||
start = position(lines, first)
|
||||
finish = position(lines, last, round_up: true)
|
||||
if ([integer(first["line"], "line"), integer(first["character"], "character")] <=>
|
||||
[integer(last["line"], "line"), integer(last["character"], "character")]) == 1
|
||||
raise ArgumentError, "reversed LSP range"
|
||||
end
|
||||
[start, finish]
|
||||
end
|
||||
|
||||
def self.outbound_position(text, row, column)
|
||||
line = lines(text).fetch(row) { raise ArgumentError, "definition source row is outside the document" }
|
||||
clusters = line.each_grapheme_cluster.to_a
|
||||
raise ArgumentError, "definition source column is outside the line" unless column.between?(0, clusters.length)
|
||||
{ "line" => row, "character" => clusters.take(column).join.encode(Encoding::UTF_16LE).bytesize / 2 }
|
||||
end
|
||||
|
||||
class Server
|
||||
attr_reader :root, :config, :documents, :pending, :requests, :ready
|
||||
attr_accessor :sync, :definition
|
||||
|
||||
def initialize(config, root, events)
|
||||
@config, @root, @events = config, root, events
|
||||
@documents, @pending, @requests = {}, [], {}
|
||||
@ready, @dead, @closing = false, false, false
|
||||
@next_id = 1
|
||||
@writes = SizedQueue.new(QUEUE_SIZE)
|
||||
@stdin, @stdout, @stderr, @wait = Open3.popen3(*config.fetch("command"), chdir: root)
|
||||
[@stdin, @stdout, @stderr].each(&:binmode)
|
||||
@reader = Thread.new do
|
||||
loop { @events.push([:incoming, self, read_frame], true) }
|
||||
rescue StandardError => error
|
||||
failed(error)
|
||||
end
|
||||
@writer = Thread.new do
|
||||
loop do
|
||||
bytes = @writes.pop
|
||||
break if bytes.nil?
|
||||
@stdin.write(bytes)
|
||||
@stdin.flush
|
||||
end
|
||||
rescue StandardError => error
|
||||
failed(error)
|
||||
end
|
||||
@drain = Thread.new do
|
||||
# Server stderr is intentionally not editor status or host protocol output.
|
||||
loop { @stderr.readpartial(8192) }
|
||||
rescue EOFError, IOError
|
||||
nil
|
||||
end
|
||||
request("initialize", {
|
||||
"processId" => Process.pid,
|
||||
"clientInfo" => { "name" => "vamp" },
|
||||
"rootUri" => VampLsp.uri(root),
|
||||
"workspaceFolders" => [{ "uri" => VampLsp.uri(root), "name" => File.basename(root) }],
|
||||
"capabilities" => {
|
||||
"general" => { "positionEncodings" => ["utf-16"] },
|
||||
"workspace" => { "configuration" => true, "workspaceFolders" => true },
|
||||
"textDocument" => {
|
||||
"synchronization" => { "dynamicRegistration" => false, "willSave" => false,
|
||||
"willSaveWaitUntil" => false, "didSave" => true },
|
||||
"definition" => { "dynamicRegistration" => false, "linkSupport" => true },
|
||||
"publishDiagnostics" => { "relatedInformation" => false, "versionSupport" => true }
|
||||
}
|
||||
}
|
||||
}, :initialize)
|
||||
rescue StandardError
|
||||
stop
|
||||
raise
|
||||
end
|
||||
|
||||
def dead?
|
||||
@dead
|
||||
end
|
||||
|
||||
def initialized!
|
||||
@ready = true
|
||||
end
|
||||
|
||||
def request(method, params, context)
|
||||
raise ArgumentError, "LSP request IDs exhausted" if @next_id > MAX_VERSION
|
||||
raise ThreadError, "LSP pending request queue is full" if @requests.length >= QUEUE_SIZE
|
||||
id = @next_id
|
||||
@next_id += 1
|
||||
@requests[id] = context
|
||||
frame = { "jsonrpc" => "2.0", "id" => id, "method" => method }
|
||||
frame["params"] = params unless params.nil?
|
||||
send_frame(frame)
|
||||
end
|
||||
|
||||
def notify(method, params)
|
||||
frame = { "jsonrpc" => "2.0", "method" => method }
|
||||
frame["params"] = params unless params.nil?
|
||||
send_frame(frame)
|
||||
end
|
||||
|
||||
def reply(id, result: nil, error: nil)
|
||||
frame = { "jsonrpc" => "2.0", "id" => id }
|
||||
frame[error ? "error" : "result"] = error || result
|
||||
send_frame(frame)
|
||||
end
|
||||
|
||||
def shutdown
|
||||
return if @dead || @closing
|
||||
@closing = true
|
||||
if @ready
|
||||
request("shutdown", nil, :shutdown)
|
||||
else
|
||||
stop
|
||||
end
|
||||
end
|
||||
|
||||
def stop
|
||||
return if @dead
|
||||
@dead = true
|
||||
[@stdin, @stdout, @stderr].compact.each { |io| io.close rescue nil }
|
||||
[@reader, @writer, @drain].compact.each { |thread| thread.kill unless thread == Thread.current }
|
||||
if @wait
|
||||
(Process.kill("TERM", @wait.pid) rescue nil) if @wait.alive?
|
||||
# Reaping and forced termination never block the host callback/dispatcher.
|
||||
Thread.new do
|
||||
unless @wait.join(0.2)
|
||||
Process.kill("KILL", @wait.pid) rescue nil
|
||||
@wait.join
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def send_frame(frame)
|
||||
raise IOError, "LSP server is unavailable" if @dead
|
||||
body = JSON.generate(frame).encode(Encoding::UTF_8)
|
||||
raise ArgumentError, "LSP outgoing frame exceeds 4 MiB" if body.bytesize > MAX_BODY
|
||||
@writes.push("Content-Length: #{body.bytesize}\r\n\r\n".b + body.b, true)
|
||||
rescue ThreadError
|
||||
raise ThreadError, "LSP outgoing queue is full"
|
||||
end
|
||||
|
||||
def read_frame
|
||||
size = 0
|
||||
length = nil
|
||||
loop do
|
||||
line = @stdout.gets("\r\n", MAX_HEADER + 1)
|
||||
raise EOFError, "LSP server exited" unless line
|
||||
size += line.bytesize
|
||||
raise ArgumentError, "LSP headers exceed 16 KiB" if size > MAX_HEADER
|
||||
raise ArgumentError, "LSP headers must be ASCII" unless line.ascii_only?
|
||||
raise ArgumentError, "invalid LSP header newline" unless line.end_with?("\r\n")
|
||||
break if line == "\r\n"
|
||||
name, value = line.chomp("\r\n").split(":", 2)
|
||||
raise ArgumentError, "invalid LSP header" unless value
|
||||
if name.downcase == "content-length"
|
||||
raise ArgumentError, "invalid LSP Content-Length" if length || !value.strip.match?(/\A[0-9]+\z/)
|
||||
length = value.to_i
|
||||
elsif name.downcase == "content-type" && value.match?(/charset=/i) &&
|
||||
!value.match?(/charset\s*=\s*utf-?8\s*\z/i)
|
||||
raise ArgumentError, "unsupported LSP content encoding"
|
||||
end
|
||||
end
|
||||
raise ArgumentError, "invalid LSP body length" unless length && length.between?(1, MAX_BODY)
|
||||
body = @stdout.read(length)
|
||||
raise EOFError, "truncated LSP body" unless body && body.bytesize == length
|
||||
body.force_encoding(Encoding::UTF_8)
|
||||
raise ArgumentError, "invalid UTF-8 LSP body" unless body.valid_encoding?
|
||||
JSON.parse(body)
|
||||
end
|
||||
|
||||
def failed(error)
|
||||
return if @dead || @closing
|
||||
@events.push([:failure, self, error.message], true)
|
||||
rescue ThreadError
|
||||
begin
|
||||
Vamp.publish([{ "type" => "message", "text" => "LSP incoming queue is full" }])
|
||||
ensure
|
||||
Kernel.exit!(1) # Host EOF retires the feature and clears all owned diagnostics.
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.clear(doc)
|
||||
{ "type" => "diagnostics", "buffer_id" => doc.fetch("id"),
|
||||
"revision" => doc.fetch("revision"), "items" => [] }
|
||||
end
|
||||
|
||||
class Client
|
||||
def initialize(options)
|
||||
servers = options["servers"]
|
||||
unless servers.is_a?(Array) && !servers.empty? && servers.length <= 16
|
||||
raise ArgumentError, "LSP options.servers must contain 1..16 server configurations"
|
||||
end
|
||||
@configs = servers.map do |config|
|
||||
unless config.is_a?(Hash) && config["language_id"].is_a?(String) && !config["language_id"].empty? &&
|
||||
config["extensions"].is_a?(Array) && !config["extensions"].empty? &&
|
||||
config["extensions"].all? { |ext| ext.is_a?(String) && ext.match?(/\A[^.\/\\\s\x00]+\z/) } &&
|
||||
config["command"].is_a?(Array) && !config["command"].empty? &&
|
||||
config["command"].all? { |arg| arg.is_a?(String) && !arg.include?("\0") } &&
|
||||
!config["command"].first.empty?
|
||||
raise ArgumentError, "LSP server requires language_id, extensions, and command argv"
|
||||
end
|
||||
config
|
||||
end
|
||||
@events = SizedQueue.new(QUEUE_SIZE)
|
||||
@documents, @servers = {}, {}
|
||||
@failure = nil
|
||||
@worker = Thread.new { run }
|
||||
end
|
||||
|
||||
def enqueue(*event)
|
||||
raise IOError, @failure if @failure
|
||||
@events.push(event, true)
|
||||
nil
|
||||
rescue ThreadError
|
||||
raise IOError, "LSP document/command queue is full"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def run
|
||||
loop do
|
||||
event, *args = @events.pop
|
||||
case event
|
||||
when :document then document(*args)
|
||||
when :definition then definition(*args)
|
||||
when :diagnostics then diagnostics_status(*args)
|
||||
when :incoming then incoming(*args)
|
||||
when :failure then fail_server(*args)
|
||||
when :shutdown
|
||||
@servers.values.compact.each(&:shutdown)
|
||||
# The host process-group cleanup is the final safety net, including descendants.
|
||||
end
|
||||
rescue StandardError => error
|
||||
server = [:incoming, :failure].include?(event) ? args.first : nil
|
||||
if server.is_a?(Server) && !error.is_a?(ThreadError)
|
||||
fail_server(server, error.message)
|
||||
else
|
||||
@failure = "LSP client: #{VampLsp.clean(error.message)}"
|
||||
@servers.values.compact.each(&:stop)
|
||||
begin
|
||||
Vamp.publish([{ "type" => "message", "text" => @failure }])
|
||||
ensure
|
||||
Kernel.exit!(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def message(text, request_id: nil)
|
||||
Vamp.publish([{ "type" => "message", "text" => "LSP: #{VampLsp.clean(text)}" }], request_id: request_id)
|
||||
end
|
||||
|
||||
def server_for(doc)
|
||||
return unless doc["path"]
|
||||
extension = File.extname(doc["path"]).delete_prefix(".")
|
||||
index = @configs.index { |config| config["extensions"].include?(extension) }
|
||||
return unless index
|
||||
key = [index, File.expand_path(doc.fetch("root"))]
|
||||
return @servers[key] if @servers.key?(key)
|
||||
begin
|
||||
@servers[key] = Server.new(@configs[index], key.last, @events)
|
||||
rescue StandardError => error
|
||||
# Remember failed starts: no automatic retries or restart loop.
|
||||
@servers[key] = nil
|
||||
message("#{@configs[index]["language_id"]} server failed to start: #{error.message}")
|
||||
end
|
||||
end
|
||||
|
||||
def document(event)
|
||||
kind = event.fetch("event")
|
||||
snapshot = event.fetch("document")
|
||||
id = snapshot.fetch("id")
|
||||
old = @documents[id]
|
||||
if kind == "close"
|
||||
if old && (server = old["server"])
|
||||
close_document(server, old)
|
||||
end
|
||||
@documents.delete(id)
|
||||
return
|
||||
end
|
||||
doc = snapshot.merge("uri" => snapshot["path"] && VampLsp.uri(snapshot["path"]),
|
||||
"server" => old ? old["server"] : server_for(snapshot),
|
||||
"version" => old ? old["version"] : 1,
|
||||
"opened" => old ? old["opened"] : false,
|
||||
"saved" => kind == "save")
|
||||
server = doc["server"]
|
||||
@documents[id] = doc
|
||||
return unless server && !server.dead?
|
||||
if old && old["revision"] != doc["revision"]
|
||||
raise ArgumentError, "LSP document version exhausted" if doc["version"] == MAX_VERSION
|
||||
doc["version"] += 1
|
||||
Vamp.publish([VampLsp.clear(doc)])
|
||||
end
|
||||
server.documents[doc["uri"]] = doc
|
||||
return unless server.ready
|
||||
if !doc["opened"]
|
||||
open_document(server, doc)
|
||||
elsif old && old["revision"] != doc["revision"]
|
||||
change_document(server, old, doc)
|
||||
end
|
||||
save_document(server, doc) if kind == "save"
|
||||
end
|
||||
|
||||
def open_document(server, doc)
|
||||
doc["opened"] = true
|
||||
return unless server.sync["openClose"]
|
||||
server.notify("textDocument/didOpen", {
|
||||
"textDocument" => { "uri" => doc["uri"], "languageId" => server.config["language_id"],
|
||||
"version" => doc["version"], "text" => doc["text"] }
|
||||
})
|
||||
end
|
||||
|
||||
def change_document(server, old, doc)
|
||||
kind = server.sync["change"]
|
||||
return if kind == 0
|
||||
change = { "text" => doc["text"] }
|
||||
if kind == 2
|
||||
old_lines = VampLsp.lines(old["text"])
|
||||
change["range"] = { "start" => { "line" => 0, "character" => 0 },
|
||||
"end" => { "line" => old_lines.length - 1,
|
||||
"character" => old_lines.last.encode(Encoding::UTF_16LE).bytesize / 2 } }
|
||||
end
|
||||
server.notify("textDocument/didChange", {
|
||||
"textDocument" => { "uri" => doc["uri"], "version" => doc["version"] },
|
||||
"contentChanges" => [change]
|
||||
})
|
||||
end
|
||||
|
||||
def save_document(server, doc)
|
||||
save = server.sync["save"]
|
||||
return unless save
|
||||
params = { "textDocument" => { "uri" => doc["uri"] } }
|
||||
params["text"] = doc["text"] if save.is_a?(Hash) && save["includeText"]
|
||||
server.notify("textDocument/didSave", params)
|
||||
doc["saved"] = false
|
||||
end
|
||||
|
||||
def close_document(server, doc)
|
||||
server.documents.delete(doc["uri"])
|
||||
return unless server.ready && !server.dead? && doc["opened"] && server.sync["openClose"]
|
||||
server.notify("textDocument/didClose", { "textDocument" => { "uri" => doc["uri"] } })
|
||||
end
|
||||
|
||||
def definition(source)
|
||||
doc = @documents[source.fetch("id")]
|
||||
return unless doc && doc["revision"] == source["revision"]
|
||||
server = doc["server"]
|
||||
unless server && !server.dead?
|
||||
message("no running server for this document", request_id: source["request_id"])
|
||||
return
|
||||
end
|
||||
unless server.ready
|
||||
raise ThreadError, "LSP pending command queue is full" if server.pending.length == QUEUE_SIZE
|
||||
server.pending << source
|
||||
return
|
||||
end
|
||||
unless server.definition
|
||||
message("server does not support go-to-definition", request_id: source["request_id"])
|
||||
return
|
||||
end
|
||||
server.request("textDocument/definition", {
|
||||
"textDocument" => { "uri" => doc["uri"] },
|
||||
"position" => VampLsp.outbound_position(source["text"], source["row"], source["column"])
|
||||
}, source)
|
||||
end
|
||||
|
||||
def diagnostics_status(id)
|
||||
doc = @documents[id]
|
||||
server = doc && doc["server"]
|
||||
message(if !server || server.dead?
|
||||
"no running server for this document"
|
||||
elsif !server.ready
|
||||
"server initializing"
|
||||
else
|
||||
"diagnostics update automatically from #{server.config["language_id"]}"
|
||||
end)
|
||||
end
|
||||
|
||||
def incoming(server, frame)
|
||||
return if server.dead?
|
||||
unless frame.is_a?(Hash) && frame["jsonrpc"] == "2.0"
|
||||
raise ArgumentError, "invalid LSP JSON-RPC frame"
|
||||
end
|
||||
if frame.key?("method")
|
||||
method = frame["method"]
|
||||
raise ArgumentError, "invalid LSP method" unless method.is_a?(String)
|
||||
if frame.key?("id")
|
||||
server_request(server, frame)
|
||||
elsif method == "textDocument/publishDiagnostics"
|
||||
publish_diagnostics(server, frame["params"])
|
||||
elsif method == "window/showMessage"
|
||||
params = frame["params"]
|
||||
raise ArgumentError, "invalid LSP showMessage" unless params.is_a?(Hash)
|
||||
message(params["message"])
|
||||
end
|
||||
return
|
||||
end
|
||||
id = frame["id"]
|
||||
unless id.is_a?(Integer) && server.requests.key?(id) &&
|
||||
(frame.key?("result") ^ frame.key?("error"))
|
||||
raise ArgumentError, "unexpected LSP response"
|
||||
end
|
||||
context = server.requests.delete(id)
|
||||
if context == :shutdown
|
||||
server.notify("exit", nil)
|
||||
return
|
||||
end
|
||||
if frame.key?("error")
|
||||
error = frame["error"]
|
||||
unless error.is_a?(Hash) && error["code"].is_a?(Integer) && error["message"].is_a?(String)
|
||||
raise ArgumentError, "invalid LSP error response"
|
||||
end
|
||||
raise IOError, "initialization failed: #{error["message"]}" if context == :initialize
|
||||
message("definition failed: #{error["message"]}", request_id: context["request_id"])
|
||||
elsif context == :initialize
|
||||
initialize_result(server, frame["result"])
|
||||
else
|
||||
definition_result(context, frame["result"])
|
||||
end
|
||||
end
|
||||
|
||||
def initialize_result(server, result)
|
||||
capabilities = result.is_a?(Hash) && result["capabilities"]
|
||||
raise ArgumentError, "invalid LSP initialize result" unless capabilities.is_a?(Hash)
|
||||
encoding = capabilities.fetch("positionEncoding", "utf-16")
|
||||
raise ArgumentError, "server selected unsupported position encoding #{encoding}" unless encoding == "utf-16"
|
||||
sync = capabilities.fetch("textDocumentSync", 0)
|
||||
if sync.is_a?(Integer) && sync.between?(0, 2)
|
||||
sync = { "openClose" => sync != 0, "change" => sync, "save" => false }
|
||||
elsif sync.is_a?(Hash)
|
||||
sync = { "openClose" => sync.fetch("openClose", false), "change" => sync.fetch("change", 0),
|
||||
"save" => sync.fetch("save", false) }
|
||||
else
|
||||
raise ArgumentError, "invalid LSP textDocumentSync capability"
|
||||
end
|
||||
unless [true, false].include?(sync["openClose"]) && [0, 1, 2].include?(sync["change"]) &&
|
||||
([true, false].include?(sync["save"]) ||
|
||||
(sync["save"].is_a?(Hash) && [true, false].include?(sync["save"].fetch("includeText", false))))
|
||||
raise ArgumentError, "invalid LSP synchronization options"
|
||||
end
|
||||
server.sync = sync
|
||||
server.definition = capabilities["definitionProvider"] == true || capabilities["definitionProvider"].is_a?(Hash)
|
||||
server.notify("initialized", {})
|
||||
server.initialized!
|
||||
server.documents.each_value do |doc|
|
||||
open_document(server, doc)
|
||||
save_document(server, doc) if doc["saved"]
|
||||
end
|
||||
pending = server.pending.dup
|
||||
server.pending.clear
|
||||
pending.each { |source| definition(source) }
|
||||
end
|
||||
|
||||
def server_request(server, frame)
|
||||
id = frame["id"]
|
||||
raise ArgumentError, "invalid LSP server request ID" unless id.is_a?(Integer) || id.is_a?(String)
|
||||
case frame["method"]
|
||||
when "workspace/configuration"
|
||||
params = frame["params"]
|
||||
items = params.is_a?(Hash) && params["items"]
|
||||
raise ArgumentError, "invalid LSP configuration request" unless items.is_a?(Array)
|
||||
server.reply(id, result: items.map { nil })
|
||||
when "workspace/workspaceFolders"
|
||||
server.reply(id, result: [{ "uri" => VampLsp.uri(server.root), "name" => File.basename(server.root) }])
|
||||
when "window/showMessageRequest"
|
||||
server.reply(id, result: nil)
|
||||
else
|
||||
server.reply(id, error: { "code" => -32601, "message" => "Method not supported by vamp" })
|
||||
end
|
||||
end
|
||||
|
||||
def publish_diagnostics(server, params)
|
||||
unless params.is_a?(Hash) && params["uri"].is_a?(String) && params["diagnostics"].is_a?(Array)
|
||||
raise ArgumentError, "invalid LSP publishDiagnostics"
|
||||
end
|
||||
doc = server.documents[params["uri"]]
|
||||
return unless doc
|
||||
if params.key?("version") && !params["version"].nil?
|
||||
version = params["version"]
|
||||
unless version.is_a?(Integer) && version.between?(-(1 << 31), MAX_VERSION)
|
||||
raise ArgumentError, "invalid LSP diagnostic version"
|
||||
end
|
||||
return if version < doc["version"]
|
||||
raise ArgumentError, "LSP diagnostics refer to an unknown future version" if version > doc["version"]
|
||||
end
|
||||
# LSP permits no version: only arrival-time revision is knowable in that case.
|
||||
text_lines = VampLsp.lines(doc["text"])
|
||||
items = params["diagnostics"].map do |item|
|
||||
raise ArgumentError, "invalid LSP diagnostic" unless item.is_a?(Hash)
|
||||
start, finish = VampLsp.range(text_lines, item["range"])
|
||||
severity = { 1 => "error", 2 => "warning", 3 => "information", 4 => "hint" }[item.fetch("severity", 1)]
|
||||
raise ArgumentError, "invalid LSP diagnostic severity" unless severity
|
||||
{ "start" => start, "end" => finish, "severity" => severity,
|
||||
"message" => VampLsp.clean(item["message"]) }
|
||||
end
|
||||
Vamp.publish([{ "type" => "diagnostics", "buffer_id" => doc["id"],
|
||||
"revision" => doc["revision"], "items" => items }])
|
||||
end
|
||||
|
||||
def definition_result(source, result)
|
||||
doc = @documents[source["id"]]
|
||||
return unless doc && doc["revision"] == source["revision"]
|
||||
locations = result.nil? ? [] : (result.is_a?(Array) ? result : [result])
|
||||
if locations.empty?
|
||||
message("no definition found", request_id: source["request_id"])
|
||||
return
|
||||
end
|
||||
location = locations.first
|
||||
raise ArgumentError, "invalid LSP definition location" unless location.is_a?(Hash)
|
||||
link = location.key?("targetUri")
|
||||
path = VampLsp.path(location[link ? "targetUri" : "uri"])
|
||||
identity = VampLsp.identity(path)
|
||||
cached = @documents.values.find { |candidate| candidate["path"] && VampLsp.identity(candidate["path"]) == identity }
|
||||
text = cached ? cached["text"] : File.read(path, encoding: Encoding::UTF_8)
|
||||
raise ArgumentError, "definition target is not UTF-8" unless text.valid_encoding?
|
||||
text_lines = VampLsp.lines(text)
|
||||
VampLsp.range(text_lines, location["targetRange"]) if link
|
||||
start, = VampLsp.range(text_lines, location[link ? "targetSelectionRange" : "range"])
|
||||
effects = [{ "type" => "navigate", "buffer_id" => source["id"], "revision" => source["revision"],
|
||||
"path" => path, "row" => start["row"], "column" => start["column"] }]
|
||||
if locations.length > 1
|
||||
effects << { "type" => "message", "text" => "LSP: selected first of #{locations.length} definitions" }
|
||||
end
|
||||
Vamp.publish(effects, request_id: source["request_id"])
|
||||
rescue SystemCallError, ArgumentError => error
|
||||
message("definition failed: #{error.message}", request_id: source["request_id"])
|
||||
end
|
||||
|
||||
def fail_server(server, reason)
|
||||
return if server.dead?
|
||||
server.stop
|
||||
Vamp.publish(server.documents.values.map { |doc| VampLsp.clear(doc) } +
|
||||
[{ "type" => "message", "text" => "LSP server stopped: #{VampLsp.clean(reason)}" }])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Vamp.plugin "lsp", requires: ["text", "diagnostics"], async: true
|
||||
client = VampLsp::Client.new(Vamp.options)
|
||||
Vamp.on_document { |event| client.enqueue(:document, event) }
|
||||
Vamp.command "lsp_definition" do |editor, _args|
|
||||
client.enqueue(:definition, { "id" => editor.buffer_id, "revision" => editor.revision,
|
||||
"text" => editor.text, "row" => editor.row - 1,
|
||||
"column" => editor.column - 1, "request_id" => editor.request_id })
|
||||
end
|
||||
Vamp.command("lsp_diagnostics") { |editor, _args| client.enqueue(:diagnostics, editor.buffer_id) }
|
||||
Vamp.on_shutdown { client.enqueue(:shutdown) }
|
||||
+347
-3
@@ -1,5 +1,6 @@
|
||||
use crate::clipboard::{ClipboardMode, SystemClipboard};
|
||||
use crate::files::{DockSide, FilePicker, FilePresentation, FileView};
|
||||
use crate::plugin::{Diagnostic, TextPosition};
|
||||
use serde::Serialize;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
@@ -117,6 +118,9 @@ pub struct Invocation {
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Context {
|
||||
pub buffer_id: usize,
|
||||
pub revision: u64,
|
||||
pub root: String,
|
||||
pub text: String,
|
||||
/// One-based row, including the empty row after a trailing newline.
|
||||
pub row: usize,
|
||||
@@ -689,11 +693,27 @@ pub struct BufferInfo<'a> {
|
||||
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 {
|
||||
id: usize,
|
||||
lines: Vec<String>,
|
||||
saved_lines: Vec<String>,
|
||||
text_revision: u64,
|
||||
save_revision: u64,
|
||||
diagnostics: BTreeMap<String, AnnotationSet>,
|
||||
targets: Vec<Target>,
|
||||
primary: usize,
|
||||
mode: Mode,
|
||||
@@ -733,6 +753,8 @@ impl Buffer {
|
||||
saved_lines: lines.clone(),
|
||||
lines,
|
||||
text_revision: 0,
|
||||
save_revision: 0,
|
||||
diagnostics: BTreeMap::new(),
|
||||
targets: vec![Target::caret(Cursor::default())],
|
||||
primary: 0,
|
||||
mode: Mode::Normal,
|
||||
@@ -760,6 +782,21 @@ impl Buffer {
|
||||
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 {
|
||||
@@ -937,6 +974,163 @@ 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> {
|
||||
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] {
|
||||
&self.active.lines
|
||||
}
|
||||
@@ -1103,6 +1297,9 @@ impl Editor {
|
||||
|
||||
pub fn context(&self) -> 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"),
|
||||
row: self.cursor().row + 1,
|
||||
column: self.cursor().column + 1,
|
||||
@@ -1175,6 +1372,7 @@ impl Editor {
|
||||
self.set_message("Core features cannot be disabled");
|
||||
return;
|
||||
}
|
||||
self.clear_plugin_diagnostics(name);
|
||||
if let Some(feature) = self.features.remove(name) {
|
||||
for bindings in self.bindings.values_mut() {
|
||||
bindings.retain(|_, binding| {
|
||||
@@ -2275,7 +2473,7 @@ impl Editor {
|
||||
Effect::Replace(text) => {
|
||||
let lines = split_lines(&text);
|
||||
if lines != self.active.lines {
|
||||
self.active.text_revision = self.active.text_revision.wrapping_add(1);
|
||||
self.active.text_changed();
|
||||
}
|
||||
self.active.lines = lines;
|
||||
self.active.search_match = None;
|
||||
@@ -2330,6 +2528,7 @@ impl Editor {
|
||||
match atomic_save(&path, &self.active.lines) {
|
||||
Ok(()) => {
|
||||
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.identity = Some(identity);
|
||||
self.set_message("Written");
|
||||
@@ -3176,7 +3375,7 @@ impl Editor {
|
||||
|
||||
fn restore(&mut self, snapshot: Snapshot) {
|
||||
if self.active.lines != snapshot.lines {
|
||||
self.active.text_revision = self.active.text_revision.wrapping_add(1);
|
||||
self.active.text_changed();
|
||||
}
|
||||
self.active.lines = snapshot.lines;
|
||||
self.active.targets = snapshot.targets;
|
||||
@@ -3390,7 +3589,7 @@ impl Editor {
|
||||
destinations[edit.target] = (remap(edit.start, false) + edit.caret, edit.round_up);
|
||||
}
|
||||
if text != old {
|
||||
self.active.text_revision = self.active.text_revision.wrapping_add(1);
|
||||
self.active.text_changed();
|
||||
self.active.search_match = None;
|
||||
}
|
||||
self.active.lines = text.split('\n').map(str::to_owned).collect();
|
||||
@@ -9411,4 +9610,149 @@ mod tests {
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "changed");
|
||||
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,7 @@
|
||||
pub mod clipboard;
|
||||
pub mod editor;
|
||||
pub mod files;
|
||||
pub mod plugin;
|
||||
pub mod ruby;
|
||||
pub mod session;
|
||||
pub mod syntax;
|
||||
|
||||
+4
-1
@@ -34,7 +34,10 @@ 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
|
||||
|
||||
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.
|
||||
|
||||
Optional LSP Ruby plugin: config.plugin PATH, options: { servers: [...] }
|
||||
LSP commands when enabled: :lsp_definition, :lsp_diagnostics";
|
||||
|
||||
struct Options {
|
||||
file: Option<PathBuf>,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
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,
|
||||
},
|
||||
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),
|
||||
}
|
||||
+724
-51
File diff suppressed because it is too large
Load Diff
+138
-18
@@ -8,6 +8,7 @@ protocol_in = STDIN.dup
|
||||
protocol_in.binmode
|
||||
STDOUT.reopen(STDERR)
|
||||
$stdout = STDERR
|
||||
Thread.abort_on_exception = true
|
||||
|
||||
module Vamp
|
||||
MAX_FRAME = 4 * 1024 * 1024
|
||||
@@ -18,6 +19,37 @@ module Vamp
|
||||
value.dup.force_encoding(Encoding::UTF_8)
|
||||
end
|
||||
|
||||
def self.json_value!(value)
|
||||
case value
|
||||
when Hash
|
||||
value.to_h do |key, item|
|
||||
raise TypeError, "option keys must be Strings or Symbols" unless key.is_a?(String) || key.is_a?(Symbol)
|
||||
[string!(key.to_s, "option key"), json_value!(item)]
|
||||
end
|
||||
when Array
|
||||
value.map { |item| json_value!(item) }
|
||||
when String
|
||||
string!(value, "option value")
|
||||
when Integer, TrueClass, FalseClass, NilClass
|
||||
value
|
||||
when Float
|
||||
raise ArgumentError, "option numbers must be finite" unless value.finite?
|
||||
value
|
||||
else
|
||||
raise TypeError, "options must contain only JSON values"
|
||||
end
|
||||
end
|
||||
|
||||
def self.deep_freeze(value)
|
||||
case value
|
||||
when Hash
|
||||
value.each { |key, item| deep_freeze(key); deep_freeze(item) }
|
||||
when Array
|
||||
value.each { |item| deep_freeze(item) }
|
||||
end
|
||||
value.freeze
|
||||
end
|
||||
|
||||
Action = Struct.new(:name)
|
||||
Mode = Struct.new(:name)
|
||||
|
||||
@@ -59,10 +91,11 @@ module Vamp
|
||||
@clipboard_mode = "system"
|
||||
end
|
||||
|
||||
def plugin(path)
|
||||
def plugin(path, options: {})
|
||||
path = Vamp.string!(path, "plugin path")
|
||||
raise ArgumentError, "plugin path must not be empty" if path.empty?
|
||||
@plugins << path
|
||||
raise ArgumentError, "plugin path must not be empty" if path.empty? || path.include?("\0")
|
||||
raise TypeError, "plugin options must be a Hash" unless options.is_a?(Hash)
|
||||
@plugins << { "path" => path, "options" => Vamp.json_value!(options) }
|
||||
nil
|
||||
end
|
||||
|
||||
@@ -136,20 +169,27 @@ module Vamp
|
||||
end
|
||||
|
||||
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)
|
||||
unless context.is_a?(Hash) && context.keys.sort == %w[column mode path row text]
|
||||
def initialize(context, request_id)
|
||||
unless context.is_a?(Hash) && context.keys.sort == %w[buffer_id column mode path revision root row text]
|
||||
raise ArgumentError, "invalid editor context"
|
||||
end
|
||||
@text = Vamp.string!(context["text"], "context text").freeze
|
||||
@mode = Vamp.string!(context["mode"], "context mode").freeze
|
||||
@path = context["path"].nil? ? nil : Vamp.string!(context["path"], "context path").freeze
|
||||
@root = Vamp.string!(context["root"], "context root").freeze
|
||||
@buffer_id = context["buffer_id"]
|
||||
@revision = context["revision"]
|
||||
@request_id = request_id
|
||||
@row = context["row"]
|
||||
@column = context["column"]
|
||||
unless @row.is_a?(Integer) && @row > 0 && @column.is_a?(Integer) && @column > 0
|
||||
raise ArgumentError, "context row and column must be positive integers"
|
||||
end
|
||||
unless @buffer_id.is_a?(Integer) && @buffer_id >= 0 && @revision.is_a?(Integer) && @revision >= 0
|
||||
raise ArgumentError, "context buffer_id and revision must be nonnegative integers"
|
||||
end
|
||||
@effects = []
|
||||
@effect_bytes = 0
|
||||
end
|
||||
@@ -177,12 +217,22 @@ module Vamp
|
||||
end
|
||||
end
|
||||
|
||||
def self.setup(mode)
|
||||
def self.setup(mode, options, sender)
|
||||
raise TypeError, "plugin options must be a Hash" unless options.is_a?(Hash)
|
||||
@mode = mode
|
||||
@config = Config.new
|
||||
@name = nil
|
||||
@requires = []
|
||||
@commands = {}
|
||||
@async = false
|
||||
@document_callback = nil
|
||||
@shutdown_callback = nil
|
||||
@options = deep_freeze(json_value!(options))
|
||||
@sender = sender
|
||||
end
|
||||
|
||||
def self.options
|
||||
@options
|
||||
end
|
||||
|
||||
def self.configure
|
||||
@@ -191,7 +241,7 @@ module Vamp
|
||||
yield @config
|
||||
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 already declared" unless @name.nil?
|
||||
name = string!(name, "plugin name")
|
||||
@@ -199,6 +249,8 @@ module Vamp
|
||||
raise TypeError, "requires must be an Array of Strings" unless requires.is_a?(Array)
|
||||
@requires = requires.map { |value| string!(value, "capability") }
|
||||
raise ArgumentError, "capability must not be empty" if @requires.any?(&:empty?)
|
||||
raise TypeError, "async must be true or false" unless async == true || async == false
|
||||
@async = async
|
||||
@name = name
|
||||
nil
|
||||
end
|
||||
@@ -213,11 +265,62 @@ module Vamp
|
||||
nil
|
||||
end
|
||||
|
||||
def self.on_document(&callback)
|
||||
raise ArgumentError, "document callbacks require an asynchronous plugin" unless @mode == "plugin" && @name && @async
|
||||
raise ArgumentError, "on_document requires a block" unless callback
|
||||
raise ArgumentError, "document callback already registered" if @document_callback
|
||||
@document_callback = callback
|
||||
nil
|
||||
end
|
||||
|
||||
def self.on_shutdown(&callback)
|
||||
raise ArgumentError, "shutdown callbacks require an asynchronous plugin" unless @mode == "plugin" && @name && @async
|
||||
raise ArgumentError, "on_shutdown requires a block" unless callback
|
||||
raise ArgumentError, "shutdown callback already registered" if @shutdown_callback
|
||||
@shutdown_callback = callback
|
||||
nil
|
||||
end
|
||||
|
||||
def self.shutdown
|
||||
callback, @shutdown_callback = @shutdown_callback, nil
|
||||
callback&.call
|
||||
end
|
||||
|
||||
def self.publish(effects, request_id: nil)
|
||||
raise ArgumentError, "publish requires an asynchronous plugin" unless @mode == "plugin" && @name && @async
|
||||
raise TypeError, "published effects must be an Array of Hashes" unless effects.is_a?(Array) && effects.all? { |effect| effect.is_a?(Hash) }
|
||||
unless request_id.nil? || (request_id.is_a?(Integer) && request_id > 0 && request_id <= 18_446_744_073_709_551_615)
|
||||
raise ArgumentError, "request_id must be a positive unsigned 64-bit integer or nil"
|
||||
end
|
||||
@sender.call({ "version" => 1, "type" => "update", "request_id" => request_id,
|
||||
"effects" => json_value!(effects) })
|
||||
nil
|
||||
end
|
||||
|
||||
def self.document(request)
|
||||
unless @async && request.is_a?(Hash) && request.keys.sort == %w[document event type version] &&
|
||||
request["version"].is_a?(Integer) && request["version"] == 1 && request["type"] == "document" &&
|
||||
%w[open change save close].include?(request["event"])
|
||||
raise ArgumentError, "invalid document event frame"
|
||||
end
|
||||
document = request["document"]
|
||||
unless document.is_a?(Hash) && document.keys.sort == %w[id path revision root text] &&
|
||||
document["id"].is_a?(Integer) && document["id"] >= 0 &&
|
||||
document["revision"].is_a?(Integer) && document["revision"] >= 0
|
||||
raise ArgumentError, "invalid document snapshot"
|
||||
end
|
||||
document["text"] = string!(document["text"], "document text")
|
||||
document["root"] = string!(document["root"], "document root")
|
||||
document["path"] = string!(document["path"], "document path") unless document["path"].nil?
|
||||
@document_callback&.call(deep_freeze({ "event" => request["event"], "document" => document }))
|
||||
nil
|
||||
end
|
||||
|
||||
def self.startup
|
||||
return @config.response if @mode == "config"
|
||||
raise ArgumentError, "plugin file must declare Vamp.plugin" unless @name
|
||||
{ "version" => 1, "type" => "manifest", "name" => @name,
|
||||
"requires" => @requires, "commands" => @commands.keys }
|
||||
"requires" => @requires, "commands" => @commands.keys, "async" => @async }
|
||||
end
|
||||
|
||||
def self.invoke(request)
|
||||
@@ -230,24 +333,28 @@ module Vamp
|
||||
args = string!(request["args"], "args")
|
||||
callback = @commands[command]
|
||||
raise ArgumentError, "unknown command #{command}" unless callback
|
||||
editor = Editor.new(request["context"])
|
||||
editor = Editor.new(request["context"], request["id"])
|
||||
callback.call(editor, args)
|
||||
{ "version" => 1, "type" => "result", "id" => request["id"], "effects" => editor.effects }
|
||||
end
|
||||
end
|
||||
|
||||
protocol_mutex = Mutex.new
|
||||
send_frame = lambda do |frame|
|
||||
payload = JSON.generate(frame)
|
||||
raise ArgumentError, "protocol frame exceeds 4 MiB" if payload.bytesize > Vamp::MAX_FRAME
|
||||
protocol_out.write(payload)
|
||||
protocol_out.write("\n")
|
||||
protocol_mutex.synchronize do
|
||||
payload = JSON.generate(frame)
|
||||
raise ArgumentError, "protocol frame exceeds 4 MiB" if payload.bytesize > Vamp::MAX_FRAME
|
||||
protocol_out.write(payload)
|
||||
protocol_out.write("\n")
|
||||
end
|
||||
end
|
||||
|
||||
request_id = nil
|
||||
begin
|
||||
mode, path = ARGV
|
||||
raise ArgumentError, "expected config or plugin mode and a path" unless %w[config plugin].include?(mode) && path
|
||||
Vamp.setup(mode)
|
||||
mode, path, encoded_options = ARGV
|
||||
raise ArgumentError, "expected config or plugin mode, path, and options" unless %w[config plugin].include?(mode) && path && encoded_options
|
||||
raise ArgumentError, "plugin options exceed 4 MiB" if encoded_options.bytesize > Vamp::MAX_FRAME
|
||||
Vamp.setup(mode, JSON.parse(encoded_options), send_frame)
|
||||
load path
|
||||
send_frame.call(Vamp.startup)
|
||||
if mode == "plugin"
|
||||
@@ -260,9 +367,22 @@ begin
|
||||
end
|
||||
request = JSON.parse(line)
|
||||
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
|
||||
Vamp.shutdown
|
||||
rescue Exception => error
|
||||
# No callback effects are emitted when loading or invoking raises.
|
||||
message = "#{error.class}: #{error.message}".encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
||||
|
||||
+834
-14
@@ -5,15 +5,38 @@ use std::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
editor::{Editor, Input, Invocation},
|
||||
editor::{Editor, Effect, Input, Invocation},
|
||||
plugin::{
|
||||
DocumentEvent, DocumentEventKind, DocumentSnapshot, PluginEvent, PluginUpdate,
|
||||
TextPosition, UpdateEffect,
|
||||
},
|
||||
ruby::{Config, RubyPlugin, load_config},
|
||||
};
|
||||
|
||||
struct TrackedDocument {
|
||||
snapshot: DocumentSnapshot,
|
||||
save_revision: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct InvocationSource {
|
||||
buffer_id: usize,
|
||||
revision: u64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct AsyncRequests {
|
||||
pending: BTreeMap<u64, InvocationSource>,
|
||||
latest: Option<(u64, InvocationSource)>,
|
||||
}
|
||||
|
||||
/// Owns command eligibility and execution hosts together. Frontends can inspect
|
||||
/// the editor, but cannot independently mutate its plugin registry.
|
||||
pub struct EditorSession {
|
||||
editor: Editor,
|
||||
plugins: BTreeMap<String, RubyPlugin>,
|
||||
documents: BTreeMap<usize, TrackedDocument>,
|
||||
requests: BTreeMap<String, AsyncRequests>,
|
||||
}
|
||||
|
||||
impl EditorSession {
|
||||
@@ -61,8 +84,9 @@ impl EditorSession {
|
||||
));
|
||||
}
|
||||
let mut plugins = BTreeMap::new();
|
||||
for path in config.plugins {
|
||||
match RubyPlugin::start(&path) {
|
||||
for configured in config.plugins {
|
||||
let path = configured.path;
|
||||
match RubyPlugin::start(&path, &configured.options) {
|
||||
Ok((plugin, feature)) => {
|
||||
let name = feature.name.clone();
|
||||
match editor.register(feature) {
|
||||
@@ -88,7 +112,14 @@ impl EditorSession {
|
||||
} else {
|
||||
editor.set_message(notices.join(" | "));
|
||||
}
|
||||
Ok(Self { editor, plugins })
|
||||
let mut session = Self {
|
||||
editor,
|
||||
plugins,
|
||||
documents: BTreeMap::new(),
|
||||
requests: BTreeMap::new(),
|
||||
};
|
||||
session.synchronize_documents();
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
pub fn editor(&self) -> &Editor {
|
||||
@@ -96,34 +127,326 @@ impl EditorSession {
|
||||
}
|
||||
|
||||
pub fn handle(&mut self, input: Input) {
|
||||
if let Some(invocation) = self.editor.handle(input) {
|
||||
let invocation = self.editor.handle(input);
|
||||
self.synchronize_documents();
|
||||
if let Some(invocation) = invocation {
|
||||
self.dispatch(invocation);
|
||||
self.synchronize_documents();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn execute(&mut self, command: &str) {
|
||||
if let Some(invocation) = self.editor.execute(command) {
|
||||
let invocation = self.editor.execute(command);
|
||||
self.synchronize_documents();
|
||||
if let Some(invocation) = invocation {
|
||||
self.dispatch(invocation);
|
||||
self.synchronize_documents();
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch(&mut self, invocation: Invocation) {
|
||||
let context = self.editor.context();
|
||||
let result = match self.plugins.get_mut(&invocation.plugin) {
|
||||
Some(plugin) if plugin.is_async() => {
|
||||
let requests = self.requests.entry(invocation.plugin.clone()).or_default();
|
||||
plugin
|
||||
.queue_invoke(&invocation.command, &invocation.args, &context)
|
||||
.map(|id| {
|
||||
let source = InvocationSource {
|
||||
buffer_id: context.buffer_id,
|
||||
revision: context.revision,
|
||||
};
|
||||
requests.pending.insert(id, source);
|
||||
requests.latest = Some((id, source));
|
||||
})
|
||||
}
|
||||
Some(plugin) => plugin
|
||||
.invoke(
|
||||
&invocation.command,
|
||||
&invocation.args,
|
||||
&self.editor.context(),
|
||||
)
|
||||
.invoke(&invocation.command, &invocation.args, &context)
|
||||
.and_then(|effects| self.editor.apply(effects)),
|
||||
None => Err("plugin host is unavailable".into()),
|
||||
};
|
||||
if let Err(error) = result {
|
||||
// Drop also stops a live host whose protocol-valid edits were rejected.
|
||||
drop(self.plugins.remove(&invocation.plugin));
|
||||
self.editor.disable_plugin(&invocation.plugin, &error);
|
||||
self.retire(&invocation.plugin, &error);
|
||||
}
|
||||
}
|
||||
|
||||
fn retire(&mut self, owner: &str, error: &str) {
|
||||
// Drop also stops a live host whose protocol-valid effects were rejected.
|
||||
drop(self.plugins.remove(owner));
|
||||
self.requests.remove(owner);
|
||||
self.editor.disable_plugin(owner, error);
|
||||
}
|
||||
|
||||
fn synchronize_documents(&mut self) -> bool {
|
||||
if !self.plugins.values().any(RubyPlugin::is_async) {
|
||||
self.documents.clear();
|
||||
return false;
|
||||
}
|
||||
let mut events = Vec::new();
|
||||
if !self.editor.should_quit() {
|
||||
for document in self.editor.documents() {
|
||||
let path = document.path.map(Path::to_string_lossy);
|
||||
let previous = self.documents.get(&document.id);
|
||||
let renamed = previous
|
||||
.is_some_and(|previous| previous.snapshot.path.as_deref() != path.as_deref());
|
||||
if renamed {
|
||||
events.push(DocumentEvent {
|
||||
event: DocumentEventKind::Close,
|
||||
document: previous.expect("renamed document existed").snapshot.clone(),
|
||||
});
|
||||
}
|
||||
let changed = previous.is_none_or(|previous| {
|
||||
previous.snapshot.revision != document.revision || renamed
|
||||
});
|
||||
if changed {
|
||||
let snapshot = DocumentSnapshot {
|
||||
id: document.id,
|
||||
revision: document.revision,
|
||||
path: path.map(|path| path.into_owned()),
|
||||
root: self.editor.project_root().to_string_lossy().into_owned(),
|
||||
text: document.lines.join("\n"),
|
||||
};
|
||||
events.push(DocumentEvent {
|
||||
event: if previous.is_none() || renamed {
|
||||
DocumentEventKind::Open
|
||||
} else {
|
||||
DocumentEventKind::Change
|
||||
},
|
||||
document: snapshot.clone(),
|
||||
});
|
||||
let save_revision = previous.map_or(0, |previous| previous.save_revision);
|
||||
self.documents.insert(
|
||||
document.id,
|
||||
TrackedDocument {
|
||||
snapshot,
|
||||
save_revision,
|
||||
},
|
||||
);
|
||||
}
|
||||
let tracked = self
|
||||
.documents
|
||||
.get_mut(&document.id)
|
||||
.expect("document tracked");
|
||||
if tracked.save_revision != document.save_revision {
|
||||
events.push(DocumentEvent {
|
||||
event: DocumentEventKind::Save,
|
||||
document: tracked.snapshot.clone(),
|
||||
});
|
||||
tracked.save_revision = document.save_revision;
|
||||
}
|
||||
}
|
||||
}
|
||||
let closed: Vec<_> = self
|
||||
.documents
|
||||
.keys()
|
||||
.filter(|id| self.editor.should_quit() || !self.editor.has_buffer(**id))
|
||||
.copied()
|
||||
.collect();
|
||||
for id in closed {
|
||||
let document = self.documents.remove(&id).expect("closed document existed");
|
||||
events.push(DocumentEvent {
|
||||
event: DocumentEventKind::Close,
|
||||
document: document.snapshot,
|
||||
});
|
||||
}
|
||||
let mut failures = Vec::new();
|
||||
for (owner, plugin) in &mut self.plugins {
|
||||
if !plugin.is_async() {
|
||||
continue;
|
||||
}
|
||||
for event in &events {
|
||||
if let Err(error) = plugin.notify_document(event) {
|
||||
failures.push((owner.clone(), error));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let changed = !failures.is_empty();
|
||||
for (owner, error) in failures {
|
||||
self.retire(&owner, &error);
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
fn source_is_current(&self, source: InvocationSource) -> bool {
|
||||
self.editor
|
||||
.documents()
|
||||
.any(|document| document.id == source.buffer_id && document.revision == source.revision)
|
||||
}
|
||||
|
||||
fn navigation_is_current(
|
||||
&self,
|
||||
owner: &str,
|
||||
request_id: Option<u64>,
|
||||
source: InvocationSource,
|
||||
) -> bool {
|
||||
self.editor.buffer_id() == source.buffer_id
|
||||
&& self.source_is_current(source)
|
||||
&& self
|
||||
.requests
|
||||
.get(owner)
|
||||
.and_then(|requests| requests.latest)
|
||||
.is_some_and(|(id, latest)| {
|
||||
request_id == Some(id)
|
||||
&& latest.buffer_id == source.buffer_id
|
||||
&& latest.revision == source.revision
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_update(&mut self, owner: &str, update: PluginUpdate) -> Result<bool, String> {
|
||||
// Validate the whole applicable annotation batch before changing any buffer.
|
||||
for effect in &update.effects {
|
||||
match effect {
|
||||
UpdateEffect::Diagnostics {
|
||||
buffer_id,
|
||||
revision,
|
||||
items,
|
||||
} => {
|
||||
self.editor
|
||||
.validate_diagnostics(*buffer_id, *revision, items)?;
|
||||
}
|
||||
UpdateEffect::Navigate {
|
||||
buffer_id,
|
||||
revision,
|
||||
path,
|
||||
..
|
||||
} => {
|
||||
let source = InvocationSource {
|
||||
buffer_id: *buffer_id,
|
||||
revision: *revision,
|
||||
};
|
||||
if self.navigation_is_current(owner, update.request_id, source)
|
||||
&& (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()
|
||||
);
|
||||
}
|
||||
}
|
||||
UpdateEffect::Message { .. } => {}
|
||||
}
|
||||
}
|
||||
let mut changed = false;
|
||||
for effect in update.effects {
|
||||
match effect {
|
||||
UpdateEffect::Diagnostics {
|
||||
buffer_id,
|
||||
revision,
|
||||
items,
|
||||
} => {
|
||||
changed |= self
|
||||
.editor
|
||||
.replace_validated_diagnostics(owner, buffer_id, revision, items);
|
||||
}
|
||||
UpdateEffect::Navigate {
|
||||
buffer_id,
|
||||
revision,
|
||||
path,
|
||||
row,
|
||||
column,
|
||||
} => {
|
||||
let source = InvocationSource {
|
||||
buffer_id,
|
||||
revision,
|
||||
};
|
||||
if self.navigation_is_current(owner, update.request_id, source) {
|
||||
if let Err(error) =
|
||||
self.editor.navigate_to(path, TextPosition { row, column })
|
||||
{
|
||||
self.editor
|
||||
.set_message(format!("Navigation failed: {error}"));
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
UpdateEffect::Message { text } => {
|
||||
changed |= self.editor.message() != text;
|
||||
self.editor.set_message(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
fn apply_response(
|
||||
&mut self,
|
||||
owner: &str,
|
||||
id: u64,
|
||||
effects: Vec<Effect>,
|
||||
) -> Result<bool, String> {
|
||||
let source = self
|
||||
.requests
|
||||
.get_mut(owner)
|
||||
.and_then(|requests| requests.pending.remove(&id));
|
||||
let Some(source) = source else {
|
||||
return Err("plugin response has an unknown invocation ID".into());
|
||||
};
|
||||
if !self.source_is_current(source) || self.editor.buffer_id() != source.buffer_id {
|
||||
return Ok(false);
|
||||
}
|
||||
if effects
|
||||
.iter()
|
||||
.all(|effect| matches!(effect, Effect::Message(_)))
|
||||
{
|
||||
let mut changed = false;
|
||||
for effect in effects {
|
||||
if let Effect::Message(text) = effect {
|
||||
changed |= self.editor.message() != text;
|
||||
self.editor.set_message(text);
|
||||
}
|
||||
}
|
||||
return Ok(changed);
|
||||
}
|
||||
let changed = !effects.is_empty();
|
||||
self.editor.apply(effects)?;
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// Drains ready plugin work without waiting for command or server responses.
|
||||
pub fn poll(&mut self) -> bool {
|
||||
let mut changed = self.synchronize_documents();
|
||||
let mut ready = Vec::new();
|
||||
for (owner, plugin) in &mut self.plugins {
|
||||
if plugin.is_async() {
|
||||
let result = plugin.poll();
|
||||
if match &result {
|
||||
Ok(events) => !events.is_empty(),
|
||||
Err(_) => true,
|
||||
} {
|
||||
ready.push((owner.clone(), result));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (owner, events) in ready {
|
||||
let events = match events {
|
||||
Ok(events) => events,
|
||||
Err(error) => {
|
||||
self.retire(&owner, &error);
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
for event in events {
|
||||
let result = match event {
|
||||
PluginEvent::Response { id, effects } => {
|
||||
self.apply_response(&owner, id, effects)
|
||||
}
|
||||
PluginEvent::Update(update) => self.apply_update(&owner, update),
|
||||
};
|
||||
match result {
|
||||
Ok(applied) => changed |= applied,
|
||||
Err(error) => {
|
||||
self.retire(&owner, &error);
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
changed |= self.synchronize_documents();
|
||||
changed
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -200,6 +523,65 @@ Vamp.command("healthy") { |editor, args| editor.replace "healthy #{args}" }
|
||||
"#,
|
||||
);
|
||||
}
|
||||
fn asynchronous(&self, file: Option<PathBuf>) -> EditorSession {
|
||||
self.file(
|
||||
"generic.rb",
|
||||
r#"
|
||||
Vamp.plugin "generic_fixture", requires: ["text", "diagnostics"], async: true
|
||||
log_mutex = Mutex.new
|
||||
record = ->(value) { log_mutex.synchronize { File.open(Vamp.options.fetch("log"), "a") { |file| file.puts JSON.generate(value) } } }
|
||||
Vamp.on_document { |event| record.call(event) }
|
||||
Vamp.command("fixture_barrier") { |editor, args| editor.message args }
|
||||
Vamp.command("fixture_replace") { |editor, args| editor.replace args }
|
||||
Vamp.command("fixture_crash") { |_editor, _args| Process.exit!(7) }
|
||||
["fixture_publish", "fixture_hold"].each do |name|
|
||||
Vamp.command(name) do |editor, args|
|
||||
payload = JSON.parse(args)
|
||||
effects = payload.fetch("effects")
|
||||
effects.each do |effect|
|
||||
next unless ["diagnostics", "navigate"].include?(effect["type"])
|
||||
effect["buffer_id"] ||= editor.buffer_id
|
||||
effect["revision"] ||= editor.revision
|
||||
end
|
||||
record.call({"invoke" => name, "buffer_id" => editor.buffer_id, "revision" => editor.revision,
|
||||
"row" => editor.row, "column" => editor.column, "request_id" => editor.request_id})
|
||||
if name == "fixture_hold"
|
||||
Thread.new do
|
||||
gate = payload.fetch("gate")
|
||||
sleep 0.002 until File.exist?(gate)
|
||||
Vamp.publish(effects + [{"type" => "message", "text" => "delivered #{gate}"}], request_id: editor.request_id)
|
||||
end
|
||||
else
|
||||
Vamp.publish(effects, request_id: editor.request_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
"#,
|
||||
);
|
||||
let log = self.0.join("events.jsonl");
|
||||
self.file("events.jsonl", "");
|
||||
let config = self.file(
|
||||
"async-config.rb",
|
||||
&format!(
|
||||
"Vamp.configure {{ |config| config.clipboard_mode = \"separate\"; config.plugin \"generic.rb\", options: {{log: {}}} }}",
|
||||
serde_json::to_string(&log.to_string_lossy()).unwrap(),
|
||||
),
|
||||
);
|
||||
EditorSession::open(
|
||||
file,
|
||||
vec!["text".into(), "diagnostics".into()],
|
||||
Some(&config),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn events(&self) -> Vec<serde_json::Value> {
|
||||
fs::read_to_string(self.0.join("events.jsonl"))
|
||||
.unwrap()
|
||||
.lines()
|
||||
.map(|line| serde_json::from_str(line).unwrap())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
@@ -1842,4 +2224,442 @@ end
|
||||
"x"
|
||||
);
|
||||
}
|
||||
|
||||
fn drain_until(session: &mut EditorSession, ready: impl Fn(&EditorSession) -> bool) {
|
||||
// Process-I/O timeout, not a date-dependent assertion.
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
|
||||
loop {
|
||||
session.poll();
|
||||
if ready(session) {
|
||||
return;
|
||||
}
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"plugin did not settle: {}",
|
||||
session.editor().message()
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
}
|
||||
}
|
||||
|
||||
fn barrier(session: &mut EditorSession, message: &str) {
|
||||
session.execute(&format!("fixture_barrier {message}"));
|
||||
drain_until(session, |session| session.editor().message() == message);
|
||||
}
|
||||
|
||||
fn diagnostic_effect(id: usize, revision: u64, message: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "diagnostics", "buffer_id": id, "revision": revision,
|
||||
"items": [{"start": {"row": 0, "column": 0}, "end": {"row": 0, "column": 1},
|
||||
"severity": "warning", "message": message}]
|
||||
})
|
||||
}
|
||||
|
||||
fn publish(session: &mut EditorSession, effects: Vec<serde_json::Value>) {
|
||||
session.execute(&format!(
|
||||
"fixture_publish {}",
|
||||
serde_json::json!({"effects": effects})
|
||||
));
|
||||
}
|
||||
|
||||
fn hold(session: &mut EditorSession, gate: &Path, effects: Vec<serde_json::Value>) {
|
||||
session.execute(&format!(
|
||||
"fixture_hold {}",
|
||||
serde_json::json!({"gate": gate, "effects": effects})
|
||||
));
|
||||
}
|
||||
|
||||
fn release(session: &mut EditorSession, gate: &Path) {
|
||||
fs::write(gate, "").unwrap();
|
||||
let delivered = format!("delivered {}", gate.display());
|
||||
drain_until(session, |session| session.editor().message() == delivered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn async_documents_include_unnamed_hidden_versions_real_saves_renames_and_closes() {
|
||||
let fixture = Fixture::new();
|
||||
let mut session = fixture.asynchronous(None);
|
||||
barrier(&mut session, "initial");
|
||||
let initial = fixture.events();
|
||||
assert_eq!(initial[0]["event"], "open");
|
||||
assert_eq!(initial[0]["document"]["id"], 1);
|
||||
assert_eq!(initial[0]["document"]["revision"], 0);
|
||||
assert_eq!(initial[0]["document"]["path"], serde_json::Value::Null);
|
||||
assert_eq!(initial[0]["document"]["text"], "");
|
||||
assert_eq!(
|
||||
initial[0]["document"]["root"],
|
||||
session.editor().project_root().to_string_lossy().as_ref()
|
||||
);
|
||||
session.handle(Input::Char('i'));
|
||||
for input in [
|
||||
Input::Char('😀'),
|
||||
Input::Char('e'),
|
||||
Input::Char('\u{301}'),
|
||||
Input::Enter,
|
||||
] {
|
||||
session.handle(input);
|
||||
}
|
||||
session.handle(Input::Escape);
|
||||
let revision = session.editor().text_revision();
|
||||
session.handle(Input::Char('h'));
|
||||
session.handle(Input::Char('l'));
|
||||
let renamed = fixture.0.join("saved unicode 😀.txt");
|
||||
session.execute(&format!("w {}", renamed.display()));
|
||||
session.execute("w");
|
||||
session.execute(&format!("w {}", fixture.0.join("missing/failed").display()));
|
||||
barrier(&mut session, "saved");
|
||||
assert_eq!(session.editor().text_revision(), revision);
|
||||
assert_eq!(
|
||||
session.editor().documents().next().unwrap().save_revision,
|
||||
2
|
||||
);
|
||||
assert_eq!(fs::read_to_string(&renamed).unwrap(), "😀e\u{301}\n");
|
||||
let events = fixture.events();
|
||||
assert_eq!(
|
||||
events
|
||||
.iter()
|
||||
.filter(|event| event["event"] == "change")
|
||||
.count(),
|
||||
revision as usize
|
||||
);
|
||||
let kinds: Vec<_> = events
|
||||
.iter()
|
||||
.filter_map(|event| event["event"].as_str())
|
||||
.collect();
|
||||
assert_eq!(&kinds[kinds.len() - 4..], ["close", "open", "save", "save"]);
|
||||
let reopened = events
|
||||
.iter()
|
||||
.find(|event| event["event"] == "open" && !event["document"]["path"].is_null())
|
||||
.unwrap();
|
||||
assert_eq!(reopened["document"]["revision"], revision);
|
||||
assert_eq!(reopened["document"]["text"], "😀e\u{301}\n");
|
||||
let target = fixture.file("target.txt", "target");
|
||||
session.execute(&format!("edit {}", target.display()));
|
||||
session.handle(Input::Char('x'));
|
||||
session.execute("buffer 1");
|
||||
let documents: Vec<_> = session.editor().documents().collect();
|
||||
assert_eq!(
|
||||
documents
|
||||
.iter()
|
||||
.map(|document| document.id)
|
||||
.collect::<Vec<_>>(),
|
||||
[1, 2]
|
||||
);
|
||||
assert_eq!(documents[1].lines, ["arget"]);
|
||||
assert_eq!(documents[1].revision, 1);
|
||||
assert_eq!(documents[1].save_revision, 0);
|
||||
session.execute("bdelete! 2");
|
||||
barrier(&mut session, "closed");
|
||||
let events = fixture.events();
|
||||
let closed = events
|
||||
.iter()
|
||||
.find(|event| event["event"] == "close" && event["document"]["id"] == 2)
|
||||
.unwrap();
|
||||
assert_eq!(closed["document"]["text"], "arget");
|
||||
assert_eq!(closed["document"]["revision"], 1);
|
||||
session.execute("q!");
|
||||
barrier(&mut session, "quit flushed");
|
||||
assert!(session.editor().should_quit());
|
||||
let events = fixture.events();
|
||||
let final_close = events
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|event| event["event"] == "close")
|
||||
.unwrap();
|
||||
assert_eq!(final_close["document"]["id"], 1);
|
||||
assert_eq!(
|
||||
final_close["document"]["path"],
|
||||
renamed.to_string_lossy().as_ref()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn async_annotations_and_messages_preserve_insert_group_and_ignore_stale_closed_sources() {
|
||||
let fixture = Fixture::new();
|
||||
let file = fixture.file("buffer.txt", "abc");
|
||||
let mut session = fixture.asynchronous(Some(file));
|
||||
let gate = fixture.0.join("annotations-ready");
|
||||
hold(&mut session, &gate, vec![diagnostic_effect(1, 1, "live")]);
|
||||
session.handle(Input::Char('i'));
|
||||
session.handle(Input::Char('x'));
|
||||
release(&mut session, &gate);
|
||||
assert_eq!(session.editor().mode(), crate::editor::Mode::Insert);
|
||||
assert_eq!(
|
||||
session
|
||||
.editor()
|
||||
.diagnostic_at_cursor()
|
||||
.map(|diagnostic| diagnostic.message.as_str()),
|
||||
None
|
||||
);
|
||||
assert_eq!(session.editor().diagnostics().count(), 1);
|
||||
session.handle(Input::Char('y'));
|
||||
assert_eq!(session.editor().diagnostics().count(), 0);
|
||||
session.handle(Input::Escape);
|
||||
session.handle(Input::Char('u'));
|
||||
assert_eq!(session.editor().lines(), ["abc"]);
|
||||
let current = session.editor().text_revision();
|
||||
let mut stale = diagnostic_effect(1, 0, "stale");
|
||||
stale["items"][0]["end"]["row"] = serde_json::json!(999);
|
||||
publish(
|
||||
&mut session,
|
||||
vec![
|
||||
stale,
|
||||
diagnostic_effect(999, current, "closed"),
|
||||
serde_json::json!({"type":"message","text":"stale checked"}),
|
||||
],
|
||||
);
|
||||
drain_until(&mut session, |session| {
|
||||
session.editor().message() == "stale checked"
|
||||
});
|
||||
assert_eq!(session.editor().diagnostics().count(), 0);
|
||||
assert!(session.plugins.contains_key("generic_fixture"));
|
||||
let target = fixture.file("closed.txt", "closed");
|
||||
session.execute(&format!("edit {}", target.display()));
|
||||
let gate = fixture.0.join("closed-ready");
|
||||
hold(
|
||||
&mut session,
|
||||
&gate,
|
||||
vec![diagnostic_effect(2, 0, "late close")],
|
||||
);
|
||||
session.execute("bdelete 2");
|
||||
release(&mut session, &gate);
|
||||
assert_eq!(session.editor().buffer_id(), 1);
|
||||
assert_eq!(session.editor().diagnostics().count(), 0);
|
||||
assert!(session.plugins.contains_key("generic_fixture"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_annotation_batches_retire_owner_and_clear_hidden_annotations_only() {
|
||||
let fixture = Fixture::new();
|
||||
let file = fixture.file("buffer.txt", "source");
|
||||
let target = fixture.file("target.txt", "target");
|
||||
let mut session = fixture.asynchronous(Some(file));
|
||||
session.execute(&format!("edit {}", target.display()));
|
||||
session.execute("buffer 1");
|
||||
publish(
|
||||
&mut session,
|
||||
vec![
|
||||
diagnostic_effect(1, 0, "source"),
|
||||
diagnostic_effect(2, 0, "hidden"),
|
||||
],
|
||||
);
|
||||
drain_until(&mut session, |session| {
|
||||
session.editor().diagnostics().count() == 1
|
||||
});
|
||||
let other = crate::plugin::Diagnostic {
|
||||
start: TextPosition { row: 0, column: 0 },
|
||||
end: TextPosition { row: 0, column: 1 },
|
||||
severity: crate::plugin::DiagnosticSeverity::Hint,
|
||||
message: "other owner".into(),
|
||||
};
|
||||
session
|
||||
.editor
|
||||
.replace_diagnostics("other", 1, 0, vec![other])
|
||||
.unwrap();
|
||||
let mut invalid = diagnostic_effect(1, 0, "bad range");
|
||||
invalid["items"][0]["end"]["column"] = serde_json::json!(999);
|
||||
publish(
|
||||
&mut session,
|
||||
vec![diagnostic_effect(2, 0, "must not commit"), invalid],
|
||||
);
|
||||
drain_until(&mut session, |session| {
|
||||
!session.plugins.contains_key("generic_fixture")
|
||||
});
|
||||
assert!(
|
||||
session
|
||||
.editor()
|
||||
.message()
|
||||
.contains("Diagnostic position is outside")
|
||||
);
|
||||
assert_eq!(session.editor().diagnostics().count(), 1);
|
||||
assert_eq!(
|
||||
session.editor().diagnostic_at_cursor().unwrap().message,
|
||||
"other owner"
|
||||
);
|
||||
session.execute("buffer 2");
|
||||
assert_eq!(session.editor().diagnostics().count(), 0);
|
||||
session.execute("fixture_publish {}");
|
||||
assert!(session.editor().message().contains("Unknown command"));
|
||||
session.handle(Input::Char('x'));
|
||||
assert_eq!(session.editor().lines(), ["arget"]);
|
||||
session.handle(Input::Char('u'));
|
||||
assert_eq!(session.editor().lines(), ["target"]);
|
||||
}
|
||||
|
||||
fn navigation_effect(path: &Path) -> serde_json::Value {
|
||||
serde_json::json!({"type":"navigate", "path":path, "row":0, "column":1})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn async_navigation_reuses_dirty_target_and_reports_open_failure_without_retiring() {
|
||||
let fixture = Fixture::new();
|
||||
let source = fixture.file("buffer.txt", "source");
|
||||
let target = fixture.file("dirty 😀 target.txt", "a😀e\u{301}");
|
||||
let mut session = fixture.asynchronous(Some(source));
|
||||
session.execute(&format!("edit {}", target.display()));
|
||||
session.handle(Input::Char('i'));
|
||||
session.handle(Input::Char('!'));
|
||||
session.handle(Input::Escape);
|
||||
session.execute("buffer 1");
|
||||
publish(&mut session, vec![navigation_effect(&target)]);
|
||||
drain_until(&mut session, |session| session.editor().buffer_id() == 2);
|
||||
assert_eq!(session.editor().lines(), ["!a😀e\u{301}"]);
|
||||
assert_eq!(
|
||||
session.editor().cursor(),
|
||||
crate::editor::Cursor { row: 0, column: 1 }
|
||||
);
|
||||
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
|
||||
assert_eq!(session.editor().buffer_count(), 2);
|
||||
assert_eq!(fs::read_to_string(&target).unwrap(), "a😀e\u{301}");
|
||||
let gate = fixture.0.join("missing-ready");
|
||||
hold(
|
||||
&mut session,
|
||||
&gate,
|
||||
vec![navigation_effect(&fixture.0.join("missing"))],
|
||||
);
|
||||
fs::write(&gate, "").unwrap();
|
||||
drain_until(&mut session, |session| {
|
||||
session
|
||||
.requests
|
||||
.get("generic_fixture")
|
||||
.is_some_and(|requests| requests.pending.is_empty())
|
||||
&& session.editor().message().starts_with("delivered ")
|
||||
});
|
||||
assert_eq!(session.editor().buffer_id(), 2);
|
||||
assert_eq!(session.editor().lines(), ["!a😀e\u{301}"]);
|
||||
assert!(session.plugins.contains_key("generic_fixture"));
|
||||
// A standalone failure message remains visible when no later plugin message overwrites it.
|
||||
publish(
|
||||
&mut session,
|
||||
vec![navigation_effect(&fixture.0.join("missing"))],
|
||||
);
|
||||
drain_until(&mut session, |session| {
|
||||
session.editor().message().starts_with("Navigation failed:")
|
||||
});
|
||||
assert_eq!(session.editor().buffer_id(), 2);
|
||||
assert_eq!(session.editor().buffer_count(), 2);
|
||||
assert!(session.plugins.contains_key("generic_fixture"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn async_navigation_drops_superseded_changed_or_inactive_source_replies() {
|
||||
for guard in ["superseded", "changed", "inactive", "closed"] {
|
||||
let fixture = Fixture::new();
|
||||
let source = fixture.file("buffer.txt", "source");
|
||||
let target = fixture.file("target.txt", "target");
|
||||
let mut session = fixture.asynchronous(Some(source));
|
||||
let gate = fixture.0.join("navigation-ready");
|
||||
hold(&mut session, &gate, vec![navigation_effect(&target)]);
|
||||
match guard {
|
||||
"superseded" => barrier(&mut session, "new request"),
|
||||
"changed" => {
|
||||
session.handle(Input::Char('i'));
|
||||
session.handle(Input::Char('x'));
|
||||
}
|
||||
"inactive" => {
|
||||
let other = fixture.file("other.txt", "other");
|
||||
session.execute(&format!("edit {}", other.display()));
|
||||
}
|
||||
"closed" => session.execute("bdelete 1"),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
let active = session.editor().buffer_id();
|
||||
let text = session.editor().context().text;
|
||||
release(&mut session, &gate);
|
||||
assert_eq!(session.editor().buffer_id(), active, "{guard}");
|
||||
assert_eq!(session.editor().context().text, text, "{guard}");
|
||||
assert!(session.plugins.contains_key("generic_fixture"), "{guard}");
|
||||
assert!(
|
||||
!session
|
||||
.editor()
|
||||
.buffers()
|
||||
.any(|buffer| buffer.path == Some(target.as_path())),
|
||||
"{guard}"
|
||||
);
|
||||
let invokes: Vec<_> = fixture
|
||||
.events()
|
||||
.into_iter()
|
||||
.filter(|event| event["invoke"] == "fixture_hold")
|
||||
.collect();
|
||||
assert_eq!(invokes.len(), 1);
|
||||
assert_eq!(invokes[0]["buffer_id"], 1);
|
||||
assert_eq!(invokes[0]["revision"], 0);
|
||||
assert_eq!(invokes[0]["row"], 1);
|
||||
assert_eq!(invokes[0]["column"], 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_async_text_response_and_dead_host_never_break_native_typing() {
|
||||
let fixture = Fixture::new();
|
||||
let file = fixture.file("buffer.txt", "source");
|
||||
let mut session = fixture.asynchronous(Some(file));
|
||||
session.execute("fixture_replace BAD");
|
||||
session.handle(Input::Char('i'));
|
||||
session.handle(Input::Char('x'));
|
||||
drain_until(&mut session, |session| {
|
||||
session
|
||||
.requests
|
||||
.get("generic_fixture")
|
||||
.is_some_and(|requests| requests.pending.is_empty())
|
||||
});
|
||||
assert_eq!(session.editor().lines(), ["xsource"]);
|
||||
assert_eq!(session.editor().mode(), crate::editor::Mode::Insert);
|
||||
session.handle(Input::Char('y'));
|
||||
session.handle(Input::Escape);
|
||||
session.handle(Input::Char('u'));
|
||||
assert_eq!(session.editor().lines(), ["source"]);
|
||||
let revision = session.editor().text_revision();
|
||||
publish(
|
||||
&mut session,
|
||||
vec![diagnostic_effect(1, revision, "before crash")],
|
||||
);
|
||||
drain_until(&mut session, |session| {
|
||||
session.editor().diagnostics().count() == 1
|
||||
});
|
||||
session.execute("fixture_crash");
|
||||
session.handle(Input::Char('i'));
|
||||
session.handle(Input::Char('z'));
|
||||
drain_until(&mut session, |session| {
|
||||
!session.plugins.contains_key("generic_fixture")
|
||||
});
|
||||
assert_eq!(session.editor().diagnostics().count(), 0);
|
||||
assert_eq!(session.editor().mode(), crate::editor::Mode::Insert);
|
||||
session.handle(Input::Char('q'));
|
||||
session.handle(Input::Escape);
|
||||
session.handle(Input::Char('u'));
|
||||
assert_eq!(session.editor().lines(), ["source"]);
|
||||
assert!(!session.editor().should_quit());
|
||||
session.execute("plugins");
|
||||
assert!(session.editor().message().contains("core [core]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn async_navigation_invokes_once_at_primary_with_multiple_carets() {
|
||||
let fixture = Fixture::new();
|
||||
let source = fixture.file("buffer.txt", "abc\nabc");
|
||||
let target = fixture.file("target.txt", "target");
|
||||
let mut session = fixture.asynchronous(Some(source));
|
||||
session.handle(Input::Char('l'));
|
||||
session.handle(Input::Ctrl('e'));
|
||||
assert_eq!(session.editor().selection_count(), 2);
|
||||
assert_eq!(
|
||||
session.editor().cursor(),
|
||||
crate::editor::Cursor { row: 1, column: 1 }
|
||||
);
|
||||
let gate = fixture.0.join("primary-ready");
|
||||
hold(&mut session, &gate, vec![navigation_effect(&target)]);
|
||||
release(&mut session, &gate);
|
||||
assert_eq!(session.editor().path(), Some(target.as_path()));
|
||||
assert_eq!(session.editor().selection_count(), 1);
|
||||
let invokes: Vec<_> = fixture
|
||||
.events()
|
||||
.into_iter()
|
||||
.filter(|event| event["invoke"] == "fixture_hold")
|
||||
.collect();
|
||||
assert_eq!(invokes.len(), 1);
|
||||
assert_eq!(invokes[0]["row"], 2);
|
||||
assert_eq!(invokes[0]["column"], 2);
|
||||
assert_eq!(invokes[0]["revision"], 0);
|
||||
}
|
||||
}
|
||||
|
||||
+493
-4
@@ -1,9 +1,11 @@
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
collections::BTreeMap,
|
||||
fmt::Write as _,
|
||||
io::{self, Stdout},
|
||||
panic::{self, AssertUnwindSafe, PanicHookInfo},
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use crossterm::{
|
||||
@@ -29,11 +31,12 @@ use unicode_width::UnicodeWidthStr;
|
||||
use crate::{
|
||||
editor::{Editor, Input, Mode, Palette},
|
||||
files::{DockSide, FilePicker, FilePresentation, FileView},
|
||||
plugin::{Diagnostic, DiagnosticSeverity},
|
||||
session::EditorSession,
|
||||
syntax::{SyntaxCache, SyntaxKind},
|
||||
};
|
||||
|
||||
pub const CAPABILITIES: &[&str] = &["text", "styled_text"];
|
||||
pub const CAPABILITIES: &[&str] = &["text", "styled_text", "diagnostics"];
|
||||
const TAB_WIDTH: usize = 4;
|
||||
|
||||
type PanicHook = Box<dyn Fn(&PanicHookInfo<'_>) + Send + Sync + 'static>;
|
||||
@@ -136,15 +139,28 @@ pub fn run(session: &mut EditorSession) -> io::Result<()> {
|
||||
let mut terminal: Terminal<CrosstermBackend<Stdout>> =
|
||||
Terminal::new(CrosstermBackend::new(io::stdout()))?;
|
||||
let mut viewports = BufferViewports::default();
|
||||
let mut redraw = true;
|
||||
while !session.editor().should_quit() {
|
||||
terminal.draw(|frame| viewports.draw(frame, session.editor()))?;
|
||||
redraw |= session.poll();
|
||||
if redraw {
|
||||
terminal.draw(|frame| viewports.draw(frame, session.editor()))?;
|
||||
redraw = false;
|
||||
}
|
||||
if !event::poll(Duration::from_millis(25))? {
|
||||
continue;
|
||||
}
|
||||
match event::read()? {
|
||||
Event::Key(key) if key.kind != KeyEventKind::Release => {
|
||||
if let Some(input) = translate(key) {
|
||||
session.handle(input);
|
||||
redraw = true;
|
||||
}
|
||||
}
|
||||
Event::Paste(text) => session.handle(Input::Paste(text)),
|
||||
Event::Paste(text) => {
|
||||
session.handle(Input::Paste(text));
|
||||
redraw = true;
|
||||
}
|
||||
Event::Resize(_, _) => redraw = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -220,6 +236,23 @@ fn visible_line(line: &str, left: usize, width: usize) -> String {
|
||||
visible
|
||||
}
|
||||
|
||||
fn diagnostic_severity(severity: DiagnosticSeverity) -> (usize, Color, &'static str) {
|
||||
match severity {
|
||||
DiagnosticSeverity::Error => (0, Color::Red, "E"),
|
||||
DiagnosticSeverity::Warning => (1, Color::Yellow, "W"),
|
||||
DiagnosticSeverity::Information => (2, Color::Cyan, "I"),
|
||||
DiagnosticSeverity::Hint => (3, Color::DarkGray, "H"),
|
||||
}
|
||||
}
|
||||
|
||||
fn diagnostic_on_row(diagnostic: &Diagnostic, row: usize) -> bool {
|
||||
diagnostic.start.row <= row
|
||||
&& row <= diagnostic.end.row
|
||||
&& (row < diagnostic.end.row
|
||||
|| diagnostic.end.column > 0
|
||||
|| diagnostic.start == diagnostic.end)
|
||||
}
|
||||
|
||||
fn draw(
|
||||
frame: &mut Frame,
|
||||
editor: &Editor,
|
||||
@@ -284,6 +317,15 @@ fn draw(
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
let style = match editor
|
||||
.diagnostics()
|
||||
.filter(|diagnostic| diagnostic_on_row(diagnostic, row))
|
||||
.map(|diagnostic| diagnostic_severity(diagnostic.severity))
|
||||
.min_by_key(|(severity, _, _)| *severity)
|
||||
{
|
||||
Some((_, color, _)) => style.fg(color),
|
||||
None => style,
|
||||
};
|
||||
let number = if row == cursor.row {
|
||||
row + 1
|
||||
} else {
|
||||
@@ -345,6 +387,76 @@ fn draw(
|
||||
column = end;
|
||||
}
|
||||
}
|
||||
for severity in [
|
||||
DiagnosticSeverity::Hint,
|
||||
DiagnosticSeverity::Information,
|
||||
DiagnosticSeverity::Warning,
|
||||
DiagnosticSeverity::Error,
|
||||
] {
|
||||
let style = Style::default()
|
||||
.underline_color(diagnostic_severity(severity).1)
|
||||
.add_modifier(Modifier::UNDERLINED);
|
||||
for diagnostic in editor
|
||||
.diagnostics()
|
||||
.filter(|item| item.severity == severity)
|
||||
{
|
||||
let first = diagnostic.start.row.max(*top);
|
||||
let last = diagnostic.end.row.min(top.saturating_add(height - 1));
|
||||
for row in first..=last {
|
||||
if !diagnostic_on_row(diagnostic, row) {
|
||||
continue;
|
||||
}
|
||||
let start = if row == diagnostic.start.row {
|
||||
diagnostic.start.column
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let end = if row == diagnostic.end.row {
|
||||
diagnostic
|
||||
.end
|
||||
.column
|
||||
.saturating_add(usize::from(diagnostic.start == diagnostic.end))
|
||||
} else {
|
||||
usize::MAX
|
||||
};
|
||||
let line = &editor.lines()[row];
|
||||
let y = viewport.y + (row - *top) as u16;
|
||||
let right = left.saturating_add(width);
|
||||
let mut column = 0;
|
||||
let mut grapheme_count = 0;
|
||||
for (index, grapheme) in line.graphemes(true).enumerate() {
|
||||
let cells = if grapheme == "\t" {
|
||||
TAB_WIDTH - column % TAB_WIDTH
|
||||
} else {
|
||||
UnicodeWidthStr::width(grapheme)
|
||||
};
|
||||
let next = column + cells;
|
||||
if index >= start
|
||||
&& index < end
|
||||
&& (grapheme == "\t" || (column >= *left && next <= right))
|
||||
{
|
||||
for cell in column.max(*left)..next.min(right) {
|
||||
frame.buffer_mut()[(viewport.x + (cell - *left) as u16, y)]
|
||||
.set_style(style);
|
||||
}
|
||||
}
|
||||
column = next;
|
||||
grapheme_count = index + 1;
|
||||
if column >= right {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if start <= grapheme_count
|
||||
&& end > grapheme_count
|
||||
&& column >= *left
|
||||
&& column < right
|
||||
{
|
||||
frame.buffer_mut()[(viewport.x + (column - *left) as u16, y)]
|
||||
.set_style(style);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(found) = editor.search_match()
|
||||
&& found.start.row >= *top
|
||||
&& found.start.row < top.saturating_add(height)
|
||||
@@ -419,6 +531,15 @@ fn draw(
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let mut diagnostic_counts = [0; 4];
|
||||
for diagnostic in editor.diagnostics() {
|
||||
diagnostic_counts[diagnostic_severity(diagnostic.severity).0] += 1;
|
||||
}
|
||||
for (label, count) in ["E", "W", "I", "H"].into_iter().zip(diagnostic_counts) {
|
||||
if count > 0 {
|
||||
write!(status, " {label}:{count}").unwrap();
|
||||
}
|
||||
}
|
||||
write!(
|
||||
status,
|
||||
" {}{} {}:{}",
|
||||
@@ -462,7 +583,18 @@ fn draw(
|
||||
prompt_area.y,
|
||||
));
|
||||
} else {
|
||||
frame.render_widget(Paragraph::new(editor.message()), prompt_area);
|
||||
let message = if let Some(diagnostic) = editor.diagnostic_at_cursor() {
|
||||
let (_, _, label) = diagnostic_severity(diagnostic.severity);
|
||||
let diagnostic = display_text(&diagnostic.message);
|
||||
Cow::Owned(if editor.message().is_empty() {
|
||||
format!("{label}: {diagnostic}")
|
||||
} else {
|
||||
format!("{} | {label}: {diagnostic}", editor.message())
|
||||
})
|
||||
} else {
|
||||
Cow::Borrowed(editor.message())
|
||||
};
|
||||
frame.render_widget(Paragraph::new(message), prompt_area);
|
||||
if height > 0 && width > 0 {
|
||||
let column = display_column(&editor.lines()[cursor.row], cursor.column);
|
||||
frame.set_cursor_position((
|
||||
@@ -845,6 +977,339 @@ mod tests {
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn annotation(
|
||||
start: (usize, usize),
|
||||
end: (usize, usize),
|
||||
severity: DiagnosticSeverity,
|
||||
message: &str,
|
||||
) -> Diagnostic {
|
||||
Diagnostic {
|
||||
start: crate::plugin::TextPosition {
|
||||
row: start.0,
|
||||
column: start.1,
|
||||
},
|
||||
end: crate::plugin::TextPosition {
|
||||
row: end.0,
|
||||
column: end.1,
|
||||
},
|
||||
severity,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostic_underlines_preserve_syntax_and_whole_graphemes_at_viewport_edges() {
|
||||
let directory = Directory::new();
|
||||
let source = "#a\t界e\u{301}👩🚀z";
|
||||
let mut editor = Editor::open(Some(directory.file("unicode.py", source)), vec![]).unwrap();
|
||||
editor
|
||||
.replace_diagnostics(
|
||||
"fixture",
|
||||
editor.buffer_id(),
|
||||
editor.text_revision(),
|
||||
vec![
|
||||
annotation((0, 2), (0, 6), DiagnosticSeverity::Warning, "range"),
|
||||
annotation((0, 4), (0, 5), DiagnosticSeverity::Error, "combining"),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
let mut syntax = SyntaxCache::new();
|
||||
for (offset, width, caret, underlines) in [
|
||||
(
|
||||
2,
|
||||
5,
|
||||
4,
|
||||
vec![
|
||||
Some(Color::Yellow),
|
||||
Some(Color::Yellow),
|
||||
Some(Color::Yellow),
|
||||
Some(Color::Yellow),
|
||||
Some(Color::Red),
|
||||
],
|
||||
),
|
||||
(5, 2, 4, vec![None, Some(Color::Red)]),
|
||||
(7, 2, 5, vec![Some(Color::Yellow), Some(Color::Yellow)]),
|
||||
(8, 2, 6, vec![None, None]),
|
||||
(
|
||||
0,
|
||||
5,
|
||||
2,
|
||||
vec![None, None, Some(Color::Yellow), Some(Color::Yellow), None],
|
||||
),
|
||||
] {
|
||||
editor.handle(Input::Home);
|
||||
for _ in 0..caret {
|
||||
editor.handle(Input::Right);
|
||||
}
|
||||
let area = Rect::new(3, 2, width + 2, 4);
|
||||
let mut terminal = Terminal::with_options(
|
||||
ratatui::backend::TestBackend::new(20, 8),
|
||||
ratatui::TerminalOptions {
|
||||
viewport: ratatui::Viewport::Fixed(area),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let (mut top, mut left) = (0, offset);
|
||||
let rendered = terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap()
|
||||
.buffer
|
||||
.clone();
|
||||
assert_eq!(left, offset);
|
||||
for (cell, underline) in underlines.into_iter().enumerate() {
|
||||
let position = (area.x + 2 + cell as u16, area.y);
|
||||
assert_eq!(
|
||||
rendered[position].modifier.contains(Modifier::UNDERLINED),
|
||||
underline.is_some(),
|
||||
"left={offset}, cell={cell}",
|
||||
);
|
||||
if let Some(color) = underline {
|
||||
assert_eq!(rendered[position].underline_color, color);
|
||||
assert_eq!(rendered[position].fg, Color::DarkGray);
|
||||
}
|
||||
assert_eq!(rendered[position].bg, Color::Reset);
|
||||
}
|
||||
assert_eq!(rendered[(area.x, area.y)].fg, Color::Red);
|
||||
assert_eq!(
|
||||
terminal.backend().buffer()[(0, 0)].modifier,
|
||||
Modifier::empty()
|
||||
);
|
||||
assert_eq!(editor.lines(), &[source]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostics_render_newlines_anchors_counts_and_messages_without_hiding_core_errors() {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
editor
|
||||
.apply(vec![crate::editor::Effect::Replace(
|
||||
"abc\n\nxy\nlast".into(),
|
||||
)])
|
||||
.unwrap();
|
||||
editor
|
||||
.replace_diagnostics(
|
||||
"fixture",
|
||||
editor.buffer_id(),
|
||||
editor.text_revision(),
|
||||
vec![
|
||||
annotation((0, 2), (0, 2), DiagnosticSeverity::Error, "point"),
|
||||
annotation((0, 1), (3, 0), DiagnosticSeverity::Warning, "multiline"),
|
||||
annotation((3, 4), (3, 4), DiagnosticSeverity::Information, "end"),
|
||||
annotation((3, 0), (3, 4), DiagnosticSeverity::Hint, "hint"),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
editor.handle(Input::Home);
|
||||
editor.handle(Input::Right);
|
||||
editor.handle(Input::Right);
|
||||
editor.set_message("write failed: permission denied");
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 7)).unwrap();
|
||||
let mut viewports = BufferViewports::default();
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
for (position, color) in [
|
||||
((3, 0), Color::Yellow),
|
||||
((4, 0), Color::Red),
|
||||
((5, 0), Color::Yellow),
|
||||
((2, 1), Color::Yellow),
|
||||
((2, 2), Color::Yellow),
|
||||
((3, 2), Color::Yellow),
|
||||
((4, 2), Color::Yellow),
|
||||
((2, 3), Color::DarkGray),
|
||||
((6, 3), Color::Cyan),
|
||||
] {
|
||||
assert!(buffer[position].modifier.contains(Modifier::UNDERLINED));
|
||||
assert_eq!(buffer[position].underline_color, color);
|
||||
}
|
||||
for (row, color) in [
|
||||
(0, Color::Red),
|
||||
(1, Color::Yellow),
|
||||
(2, Color::Yellow),
|
||||
(3, Color::Cyan),
|
||||
] {
|
||||
assert_eq!(buffer[(0, row)].fg, color);
|
||||
}
|
||||
assert!(!buffer[(6, 0)].modifier.contains(Modifier::UNDERLINED));
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains("E:1 W:1 I:1 H:1"), "{text}");
|
||||
assert!(
|
||||
text.contains("write failed: permission denied | E: point"),
|
||||
"{text}"
|
||||
);
|
||||
assert_eq!(
|
||||
terminal.get_cursor_position().unwrap(),
|
||||
ratatui::layout::Position::new(4, 0),
|
||||
);
|
||||
|
||||
editor.handle(Input::Char('i'));
|
||||
editor.handle(Input::Char('!'));
|
||||
assert_eq!(editor.mode(), Mode::Insert);
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert_eq!(editor.diagnostics().count(), 0);
|
||||
assert!(!screen_text(&terminal).contains("E:1"));
|
||||
assert!(
|
||||
!terminal.backend().buffer()[(4, 0)]
|
||||
.modifier
|
||||
.contains(Modifier::UNDERLINED)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_session_updates_redraw_annotations_and_navigation_while_native_editing_continues() {
|
||||
let directory = Directory::new();
|
||||
let source = directory.file("source.txt", "broken TARGET");
|
||||
let target_text = format!("{}{}TARGET", "# line\n".repeat(15), "#".repeat(40));
|
||||
let target = directory.file("target.py", &target_text);
|
||||
directory.file(
|
||||
"semantic.rb",
|
||||
r#"
|
||||
Vamp.plugin "semantic", requires: ["text", "diagnostics"], async: true
|
||||
work = Queue.new
|
||||
Vamp.on_document { |event| work << ["document", event] }
|
||||
Vamp.command "fixture_definition" do |editor, _|
|
||||
raise "No target reference" unless editor.text.include?("TARGET")
|
||||
work << ["definition", editor.buffer_id, editor.revision, editor.request_id]
|
||||
end
|
||||
Thread.new do
|
||||
documents = {}
|
||||
loop do
|
||||
kind, *args = work.pop
|
||||
if kind == "document"
|
||||
event = args[0]
|
||||
document = event.fetch("document")
|
||||
if event.fetch("event") == "close"
|
||||
documents.delete(document.fetch("id"))
|
||||
next
|
||||
end
|
||||
documents[document.fetch("id")] = document
|
||||
next unless ["open", "change"].include?(event.fetch("event"))
|
||||
items = []
|
||||
document.fetch("text").split("\n", -1).each_with_index do |line, row|
|
||||
graphemes = line.each_grapheme_cluster.to_a
|
||||
graphemes.each_cons(6).with_index do |token, column|
|
||||
next unless token.join == "broken"
|
||||
items << {start: {row: row, column: column},
|
||||
end: {row: row, column: column + 6},
|
||||
severity: "error", message: "Unresolved token"}
|
||||
end
|
||||
end
|
||||
Vamp.publish([{type: "diagnostics", buffer_id: document.fetch("id"),
|
||||
revision: document.fetch("revision"), items: items}])
|
||||
else
|
||||
target = documents.values.find { |document| document["path"] == Vamp.options.fetch("target") }
|
||||
raise "Target document is not open" unless target
|
||||
lines = target.fetch("text").split("\n", -1)
|
||||
row = lines.index { |line| line.include?("TARGET") }
|
||||
raise "Target definition missing" unless row
|
||||
column = lines[row].each_grapheme_cluster.to_a.each_cons(6).find_index { |token| token.join == "TARGET" }
|
||||
Vamp.publish([{type: "navigate", buffer_id: args[0], revision: args[1],
|
||||
path: target.fetch("path"), row: row, column: column}],
|
||||
request_id: args[2])
|
||||
end
|
||||
end
|
||||
end
|
||||
"#,
|
||||
);
|
||||
let config = directory.file(
|
||||
"config.rb",
|
||||
&format!(
|
||||
"Vamp.configure do |config|\n config.clipboard_mode = \"separate\"\n config.plugin \"semantic.rb\", options: {{target: {}}}\nend\n",
|
||||
serde_json::to_string(&target.to_string_lossy()).unwrap(),
|
||||
),
|
||||
);
|
||||
let mut session = EditorSession::open(
|
||||
Some(source),
|
||||
CAPABILITIES
|
||||
.iter()
|
||||
.map(|capability| (*capability).into())
|
||||
.collect(),
|
||||
Some(&config),
|
||||
)
|
||||
.unwrap();
|
||||
let source_id = session.editor().buffer_id();
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(18, 6)).unwrap();
|
||||
let mut viewports = BufferViewports::default();
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, session.editor()))
|
||||
.unwrap();
|
||||
|
||||
fn poll_until(
|
||||
session: &mut EditorSession,
|
||||
terminal: &mut Terminal<ratatui::backend::TestBackend>,
|
||||
viewports: &mut BufferViewports,
|
||||
ready: impl Fn(&Editor) -> bool,
|
||||
) {
|
||||
for _ in 0..2_000 {
|
||||
if session.poll() {
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, session.editor()))
|
||||
.unwrap();
|
||||
}
|
||||
if ready(session.editor()) {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
panic!(
|
||||
"async fixture did not update editor: {}",
|
||||
session.editor().message()
|
||||
);
|
||||
}
|
||||
|
||||
session.handle(Input::Char('A'));
|
||||
session.handle(Input::Char('!'));
|
||||
assert_eq!(session.editor().mode(), Mode::Insert);
|
||||
assert_eq!(session.editor().lines(), &["broken TARGET!"]);
|
||||
poll_until(&mut session, &mut terminal, &mut viewports, |editor| {
|
||||
editor.diagnostics().count() == 1
|
||||
});
|
||||
assert!(screen_text(&terminal).contains("E:1"));
|
||||
assert!(
|
||||
terminal.backend().buffer()[(2, 0)]
|
||||
.modifier
|
||||
.contains(Modifier::UNDERLINED)
|
||||
);
|
||||
assert_eq!(session.editor().mode(), Mode::Insert);
|
||||
session.handle(Input::Escape);
|
||||
session.handle(Input::Char('u'));
|
||||
assert_eq!(session.editor().lines(), &["broken TARGET"]);
|
||||
assert_eq!(session.editor().diagnostics().count(), 0);
|
||||
|
||||
session.execute(&format!("edit {}", target.display()));
|
||||
let target_id = session.editor().buffer_id();
|
||||
session.handle(Input::Char('i'));
|
||||
session.handle(Input::Char('!'));
|
||||
session.handle(Input::Escape);
|
||||
session.execute(&format!("buffer {source_id}"));
|
||||
session.execute("fixture_definition");
|
||||
session.handle(Input::Char('l'));
|
||||
assert_eq!(session.editor().buffer_id(), source_id);
|
||||
poll_until(&mut session, &mut terminal, &mut viewports, |editor| {
|
||||
editor.buffer_id() == target_id
|
||||
});
|
||||
assert_eq!(session.editor().buffer_count(), 2);
|
||||
assert!(session.editor().dirty());
|
||||
assert_eq!(session.editor().lines()[0], "!# line");
|
||||
assert_eq!(session.editor().cursor().row, 15);
|
||||
assert_eq!(session.editor().cursor().column, 40);
|
||||
assert_eq!(session.editor().mode(), Mode::Normal);
|
||||
assert_eq!(viewports.offsets[&target_id], (12, 26));
|
||||
let cursor = terminal.get_cursor_position().unwrap();
|
||||
assert_eq!(
|
||||
terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(),
|
||||
"T"
|
||||
);
|
||||
assert_eq!(
|
||||
terminal.backend().buffer()[(cursor.x, cursor.y)].fg,
|
||||
Color::DarkGray
|
||||
);
|
||||
assert_eq!(fs::read_to_string(target).unwrap(), target_text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syntax_tokens_refresh_after_native_edits_and_undo_without_changing_text_on_draw() {
|
||||
let directory = Directory::new();
|
||||
@@ -1029,6 +1494,19 @@ mod tests {
|
||||
vec![],
|
||||
)
|
||||
.unwrap();
|
||||
editor
|
||||
.replace_diagnostics(
|
||||
"fixture",
|
||||
editor.buffer_id(),
|
||||
editor.text_revision(),
|
||||
vec![annotation(
|
||||
(0, 2),
|
||||
(2, 6),
|
||||
DiagnosticSeverity::Error,
|
||||
"overlays",
|
||||
)],
|
||||
)
|
||||
.unwrap();
|
||||
editor.handle(Input::Char('/'));
|
||||
for ch in "word".chars() {
|
||||
editor.handle(Input::Char(ch));
|
||||
@@ -1044,6 +1522,11 @@ mod tests {
|
||||
for x in 4..8 {
|
||||
assert_eq!(terminal.backend().buffer()[(x, y)].fg, Color::Black);
|
||||
assert_eq!(terminal.backend().buffer()[(x, y)].bg, Color::Yellow);
|
||||
assert!(
|
||||
terminal.backend().buffer()[(x, y)]
|
||||
.modifier
|
||||
.contains(Modifier::UNDERLINED)
|
||||
);
|
||||
}
|
||||
editor.handle(Input::Char('v'));
|
||||
editor.handle(Input::Char('e'));
|
||||
@@ -1066,6 +1549,7 @@ mod tests {
|
||||
assert_eq!(buffer[(x, y)].fg, Color::White);
|
||||
assert_eq!(buffer[(x, y)].bg, Color::Blue);
|
||||
}
|
||||
assert!(buffer[(x, y)].modifier.contains(Modifier::UNDERLINED));
|
||||
}
|
||||
assert_eq!(buffer[(2, y)].fg, Color::DarkGray);
|
||||
}
|
||||
@@ -1077,6 +1561,11 @@ mod tests {
|
||||
let x = 2 + display_column(&editor.lines()[caret.row], caret.column) as u16;
|
||||
assert_eq!(buffer[(x, caret.row as u16)].fg, Color::Black);
|
||||
assert_eq!(buffer[(x, caret.row as u16)].bg, Color::Magenta);
|
||||
assert!(
|
||||
buffer[(x, caret.row as u16)]
|
||||
.modifier
|
||||
.contains(Modifier::UNDERLINED)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Executable
+700
@@ -0,0 +1,700 @@
|
||||
#![cfg(unix)]
|
||||
|
||||
use std::{
|
||||
fs,
|
||||
path::{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, not a plugin echoing generic effects.
|
||||
// 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 && (mode != 'gated' || 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}}})
|
||||
initialized_sent = true
|
||||
end
|
||||
if !held.empty? && File.exist?(release) && mode == 'stale'
|
||||
held.each { |request| result(request['id'], location(target)) }
|
||||
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 '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!({"servers": [{
|
||||
"language_id": "rust", "extensions": ["rs"], "command": command,
|
||||
}]});
|
||||
let plugin = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/plugins/lsp.rb");
|
||||
let config = format!(
|
||||
"Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.plugin {}, options: JSON.parse({})\nend\n",
|
||||
serde_json::to_string(&plugin).unwrap(),
|
||||
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"]["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"]["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!([null, 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.execute("lsp_definition");
|
||||
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.execute("lsp_diagnostics");
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user