feat: migrate LSP to opt-in native language servers
This commit is contained in:
@@ -200,14 +200,15 @@ Leader bindings require Normal/Visual and the target's existing mode eligibility
|
|||||||
|
|
||||||
## Module seams
|
## Module seams
|
||||||
|
|
||||||
One crate, eight modules, no workspace or terminal-coordinate types in the editing backend:
|
One crate; no workspace or terminal-coordinate types in the editing backend:
|
||||||
|
|
||||||
- `editor`: owns buffer lifecycle and per-buffer text, saved state, primary/selection set, modes, search, and undo/redo; session-wide clipboard policy, independent registers and owned-system fragment metadata, semantic native repeat, action metadata, command-palette and file-picker focus/dispatch, key notation, leader/count prefixes and binding resolution, modal paste policy, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected.
|
- `editor`: owns buffer lifecycle and per-buffer text, saved state, primary/selection set, modes, search, and undo/redo; session-wide clipboard policy, independent registers and owned-system fragment metadata, semantic native repeat, action metadata, command-palette and file-picker focus/dispatch, key notation, leader/count prefixes and binding resolution, modal paste policy, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected.
|
||||||
- `files`: owns the gitignore-aware directory/file catalog, authoritative OS paths, compact tree projection, query filtering, browse/search focus, expansion/selection restoration, navigation, and scan/error state shared by floating and docked presentations.
|
- `files`: owns the gitignore-aware directory/file catalog, authoritative OS paths, compact tree projection, query filtering, browse/search focus, expansion/selection restoration, navigation, and scan/error state shared by floating and docked presentations.
|
||||||
- `clipboard`: owns native plain-text provider selection and bounded subprocess I/O; it never computes selections or editing offsets.
|
- `clipboard`: owns native plain-text provider selection and bounded subprocess I/O; it never computes selections or editing offsets.
|
||||||
- `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.
|
- `syntax`: owns Tree-sitter grammar detection, per-buffer incremental trees, bundled highlight/local/injection queries, and semantic byte spans; it has no terminal color/geometry or editing dependency.
|
||||||
- `plugin`: owns generic document snapshots/events, zero-based grapheme positions, diagnostics, and versioned asynchronous update contracts. It has no LSP protocol types.
|
- `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.
|
- `lsp`: owns opt-in native server workers, bounded framed JSON-RPC, synchronization/capability negotiation, settings, UTF-16/file-URI conversion, and definition results. Its private configuration resolver supplies presets without restricting language IDs.
|
||||||
|
- `session`: `EditorSession` owns the editor, native LSP, and Ruby hosts together. It loads configuration once, registers eligible plugins before installing shared bindings, tracks document open/change/save/close events, polls native and plugin results, validates versioned diagnostics/navigation, and retires failed plugin ownership.
|
||||||
- `tui`: translates terminal events to session input, renders read-only active-buffer state, keeps frontend-owned viewports and syntax caches by stable buffer ID, and advertises `text`, `styled_text`, and `diagnostics` capabilities. It maps semantic tokens and diagnostics to terminal styling and has no server/protocol logic or paste acceptance policy.
|
- `tui`: translates terminal events to session input, renders read-only active-buffer state, keeps frontend-owned viewports and syntax caches by stable buffer ID, and advertises `text`, `styled_text`, and `diagnostics` capabilities. It maps semantic tokens and diagnostics to terminal styling and has no server/protocol logic or paste acceptance policy.
|
||||||
- `ruby`: evaluates configuration and runs isolated plugin hosts. Protocol, deadlines, and process cleanup stay inside this module.
|
- `ruby`: evaluates configuration and runs isolated plugin hosts. Protocol, deadlines, and process cleanup stay inside this module.
|
||||||
- The executable selects paths and frontend capabilities, opens an editor session, and launches the TUI.
|
- The executable selects paths and frontend capabilities, opens an editor session, and launches the TUI.
|
||||||
@@ -220,7 +221,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 `leader_pending()` for the cached display of a pending leader sequence; prefix recognition, cancellation, and dispatch remain editor-owned.
|
||||||
Frontends inspect `buffer_id()`, `buffer_count()`, `has_buffer(id)`, and the ascending-ID `buffers()` iterator of borrowed path/dirty/active metadata. Existing text, cursor, selection, search, and mode methods describe only the active buffer.
|
Frontends inspect `buffer_id()`, `buffer_count()`, `has_buffer(id)`, and the ascending-ID `buffers()` iterator of borrowed path/dirty/active metadata. Existing text, cursor, selection, search, and mode methods describe only the active buffer.
|
||||||
Frontends inspect `file_picker()` for query, presentation, view, explorer-local INSERT/filter subfocus, selection, and errors, and iterate borrowed `FileRow` metadata (actual path, display label, directory flag, visible depth, expansion). Dock side is a logical preference. They allocate rectangles, add folder suffixes/colors, and render; hierarchy, filtering, parent/child navigation, focus transitions, and selected-file opening remain backend-owned.
|
Frontends inspect `file_picker()` for query, presentation, view, explorer-local INSERT/filter subfocus, selection, and errors, and iterate borrowed `FileRow` metadata (actual path, display label, directory flag, visible depth, expansion). Dock side is a logical preference. They allocate rectangles, add folder suffixes/colors, and render; hierarchy, filtering, parent/child navigation, focus transitions, and selected-file opening remain backend-owned.
|
||||||
Frontends inspect `diagnostics()` and `diagnostic_at_cursor()` for validated active-buffer annotations. Diagnostics are owned by 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.
|
Frontends inspect `diagnostics()` and `diagnostic_at_cursor()` for validated active-buffer annotations. Diagnostics are owned by core LSP or a plugin and buffer revision; text changes and owner/server retirement clear them. The TUI underlines annotated text, colors affected gutter numbers by severity, and displays the primary caret's diagnostic message without replacing search/selection backgrounds.
|
||||||
|
|
||||||
The terminal adapter owns raw mode, alternate-screen state, bracketed paste, and its panic hook for one session lifetime. Entry failure, normal return, I/O errors, and unwinding panics restore the caller's hook; active-terminal cleanup runs before caller panic diagnostics. Panics retain their original payload and propagate rather than being swallowed. Terminal state and panic hooks are process-global: run one TUI at a time and do not replace its hook while that session is active.
|
The terminal adapter owns raw mode, alternate-screen state, bracketed paste, and its panic hook for one session lifetime. Entry failure, normal return, I/O errors, and unwinding panics restore the caller's hook; active-terminal cleanup runs before caller panic diagnostics. Panics retain their original payload and propagate rather than being swallowed. Terminal state and panic hooks are process-global: run one TUI at a time and do not replace its hook while that session is active.
|
||||||
|
|
||||||
@@ -242,27 +243,38 @@ 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.
|
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
|
### Native LSP
|
||||||
|
|
||||||
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):
|
LSP runs in Rust, independently of Ruby plugin hosts. **Every language server is disabled by default.** Ruby evaluates configuration only at startup; no LSP plugin needs loading. Install a trusted server yourself and explicitly opt in:
|
||||||
|
|
||||||
```ruby
|
```ruby
|
||||||
config.plugin "plugins/lsp.rb", options: {
|
Vamp.configure do |config|
|
||||||
servers: [
|
config.lsp["rust"] = true # rust-analyzer, extensions: ["rs"]
|
||||||
{ language_id: "rust", extensions: ["rs"], command: ["rust-analyzer"] },
|
config.lsp["python"] = false # disabled (also the default when absent)
|
||||||
{ language_id: "python", extensions: ["py"], command: ["pylsp"] }
|
|
||||||
]
|
config.lsp["my-language"] = {
|
||||||
|
command: ["my-language-server", "--stdio"],
|
||||||
|
extensions: ["myl"],
|
||||||
|
initialization_options: {},
|
||||||
|
settings: { "my-language-server" => { "exampleOption" => true } }
|
||||||
}
|
}
|
||||||
config.bind "leader d", "lsp_definition"
|
config.bind "leader d", "lsp_definition"
|
||||||
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
Keys are arbitrary nonempty strings, used as the protocol's `languageId`; they need no Tree-sitter grammar or hardcoded language enum. Absent/`false` entries are disabled. `true` enables a built-in preset: currently `"rust"` uses `rust-analyzer`/`rs`, and `"python"` uses `pylsp`/`py`. Unknown keys need a configuration object with nonempty `command` argv and `extensions` (without leading dots). Objects also explicitly enable servers; known presets supply omitted command/extensions. For example, `config.lsp["rust"] = { command: ["/custom/path/rust-analyzer"] }` overrides the executable while retaining `rs`.
|
||||||
|
|
||||||
|
Only `command`, `extensions`, `initialization_options`, and `settings` are accepted. Both option/settings values must be JSON-compatible objects and default to `{}`. Invalid declarations reject configuration with a notice rather than activating a guessed server. Server commands are executable/argument arrays, never shell commands; use an absolute path if the executable is not on PATH. No server installation or restart is automatic.
|
||||||
|
|
||||||
|
Servers start lazily only when a matching buffer opens. Extension matching is case-sensitive; if enabled languages overlap, the first language in sorted key order wins. One worker runs per language configuration/project root, with at most 16 enabled languages/server-project keys. Start vamp from your project root: its startup working directory is the workspace; automatic root discovery is not implemented.
|
||||||
|
|
||||||
|
The native module initializes each server, sends `initialization_options` as `initializationOptions`, answers `workspace/configuration` from `settings` (including dotted sections; missing sections return `null`), honors open/change/save capabilities, synchronizes in-memory text including hidden dirty buffers, and sends close events. Incremental servers receive whole-document range replacements. Process/pipe I/O and requests stay on bounded background workers; input and idle diagnostic redraw do not wait for server responses.
|
||||||
|
|
||||||
`:lsp_definition` or the configured Space d binding requests a definition at the primary caret, once regardless of selection count. Both Location and LocationLink results are supported; multiple results use the first. Local-file navigation reuses loaded buffers without overwriting unsaved text. LSP UTF-16 positions and percent-encoded file URIs are translated to validated grapheme coordinates. Navigation is ignored if its source buffer, revision, caret, or latest request has changed.
|
`: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.
|
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.
|
Native LSP 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. The former Ruby LSP plugin has been removed; migrate its `servers` entries to `config.lsp[language_id]` objects without the `language_id` field.
|
||||||
|
|
||||||
## Ruby configuration
|
## Ruby configuration
|
||||||
|
|
||||||
@@ -381,7 +393,7 @@ JSON uses Ruby's standard library. Protocol Buffers is not installed or required
|
|||||||
|
|
||||||
## Current limits
|
## Current limits
|
||||||
|
|
||||||
- One visible buffer at a time; Normal, Insert, Command, Search, and characterwise Visual modes. No named registers, line/block selection, Vim operators, splits/tabs, regex/multiline search, 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.
|
- One visible buffer at a time; Normal, Insert, Command, Search, and characterwise Visual modes. No named registers, line/block selection, Vim operators, splits/tabs, regex/multiline search, or graphical frontend. Native opt-in LSP currently supports diagnostics 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.
|
- Undo uses full-buffer snapshots and movement scans the current line. This favors straightforward correctness over large-file performance.
|
||||||
- Legacy synchronous plugin invocation may pause input for up to 500 ms before a host timeout. Async plugins opt into background replies; pending requests and transport queues are bounded. Config/plugin startup has a 2-second deadline per host. Buffer context/document snapshots must fit within a 4 MiB JSON frame; a too-large frame disables that plugin, not editing.
|
- Legacy synchronous plugin invocation may pause input for up to 500 ms before a host timeout. Async plugins opt into background replies; pending requests and transport queues are bounded. Config/plugin startup has a 2-second deadline per host. Buffer context/document snapshots must fit within a 4 MiB JSON frame; a too-large frame disables that plugin, not editing.
|
||||||
- Disabling a plugin removes its commands and bindings until the next launch. There is no hot reload.
|
- Disabling a plugin removes its commands and bindings until the next launch. There is no hot reload.
|
||||||
@@ -399,4 +411,4 @@ Regression checks exercise Unicode edits, undo/redo, saving and failed saves, ca
|
|||||||
|
|
||||||
Unix terminal-hook checks run in isolated child processes and real PTYs to keep global hooks out of the parallel test runner. They cover entry failure, normal return, I/O error, panic cleanup before caller diagnostics, original panic-payload propagation, and later panics producing no stale terminal-restoration output.
|
Unix terminal-hook checks run in isolated child processes and real PTYs to keep global hooks out of the parallel test runner. They cover entry failure, normal return, I/O error, panic cleanup before caller diagnostics, original panic-payload propagation, and later panics producing no stale terminal-restoration output.
|
||||||
|
|
||||||
LSP integration checks launch real framed-protocol fixture subprocesses 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.
|
LSP integration checks launch real framed-protocol fixture subprocesses directly from native Rust: fragmented/adjacent frames, initialization/custom settings, arbitrary language IDs, opt-in matching-only startup, negotiated synchronization, open/change/save/close/shutdown, UTF-16 and encoded Unicode paths, dirty targets without enabled LSPs, stale/superseded requests and diagnostics, moved carets, unsupported requests, crashes, malformed frames, unavailable servers, and surviving native editing. Executable PTY smoke exercised idle diagnostic redraw, active-server edits, definition navigation into a dirty non-LSP buffer, Unicode cursor placement, settings, saving, and clean shutdown; its server's parent PID confirmed there was no Ruby LSP host. No desktop clipboard or user files are touched. Windows transport/process cleanup is implemented but not exercised on this macOS workstation.
|
||||||
|
|||||||
+5
-3
@@ -13,9 +13,11 @@ Vamp.configure do |config|
|
|||||||
config.bind "ctrl+h", config.core.left, mode: config.modes.tree
|
config.bind "ctrl+h", config.core.left, mode: config.modes.tree
|
||||||
config.bind "ctrl+l", config.core.right, mode: config.modes.tree
|
config.bind "ctrl+l", config.core.right, mode: config.modes.tree
|
||||||
config.plugin "plugins/greeting.rb"
|
config.plugin "plugins/greeting.rb"
|
||||||
# Optional LSP feature; install/configure a trusted language server first.
|
# Native LSP is disabled by default; install a server and explicitly opt in.
|
||||||
# config.plugin "plugins/lsp.rb", options: {
|
# config.lsp["rust"] = true # built-in rust-analyzer preset
|
||||||
# servers: [{ language_id: "rust", extensions: ["rs"], command: ["rust-analyzer"] }]
|
# config.lsp["my-language"] = {
|
||||||
|
# command: ["my-language-server", "--stdio"], extensions: ["myl"],
|
||||||
|
# initialization_options: {}, settings: {}
|
||||||
# }
|
# }
|
||||||
# config.bind "leader d", "lsp_definition"
|
# config.bind "leader d", "lsp_definition"
|
||||||
config.bind "g", "greet"
|
config.bind "g", "greet"
|
||||||
|
|||||||
@@ -1,619 +0,0 @@
|
|||||||
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) }
|
|
||||||
+24
-2
@@ -896,8 +896,20 @@ impl Editor {
|
|||||||
name: "core".into(),
|
name: "core".into(),
|
||||||
required_capabilities: Vec::new(),
|
required_capabilities: Vec::new(),
|
||||||
commands: [
|
commands: [
|
||||||
"w", "q", "q!", "wq", "plugins", "edit", "buffers", "buffer", "bnext",
|
"w",
|
||||||
"bprev", "bdelete", "bdelete!",
|
"q",
|
||||||
|
"q!",
|
||||||
|
"wq",
|
||||||
|
"plugins",
|
||||||
|
"edit",
|
||||||
|
"buffers",
|
||||||
|
"buffer",
|
||||||
|
"bnext",
|
||||||
|
"bprev",
|
||||||
|
"bdelete",
|
||||||
|
"bdelete!",
|
||||||
|
"lsp_definition",
|
||||||
|
"lsp_diagnostics",
|
||||||
]
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(str::to_owned)
|
.map(str::to_owned)
|
||||||
@@ -2384,6 +2396,16 @@ impl Editor {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
match name {
|
match name {
|
||||||
|
"lsp_definition" | "lsp_diagnostics" if !args.is_empty() => {
|
||||||
|
self.set_message(format!("The {name} command takes no arguments"));
|
||||||
|
}
|
||||||
|
"lsp_definition" | "lsp_diagnostics" => {
|
||||||
|
return Some(Invocation {
|
||||||
|
plugin: "core".into(),
|
||||||
|
command: name.into(),
|
||||||
|
args: String::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
"w" | "wq" => {
|
"w" | "wq" => {
|
||||||
if self.save(if args.is_empty() {
|
if self.save(if args.is_empty() {
|
||||||
None
|
None
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
pub mod clipboard;
|
pub mod clipboard;
|
||||||
pub mod editor;
|
pub mod editor;
|
||||||
pub mod files;
|
pub mod files;
|
||||||
|
pub mod lsp;
|
||||||
|
mod lsp_config;
|
||||||
pub mod plugin;
|
pub mod plugin;
|
||||||
pub mod ruby;
|
pub mod ruby;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
|
|||||||
+1800
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,154 @@
|
|||||||
|
use crate::lsp::ServerConfig;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
pub fn resolve(value: Value) -> Result<Vec<ServerConfig>, String> {
|
||||||
|
let Value::Object(languages) = value else {
|
||||||
|
return Err("LSP configuration must be a language-keyed object".into());
|
||||||
|
};
|
||||||
|
let mut servers = Vec::new();
|
||||||
|
for (language_id, value) in languages {
|
||||||
|
if language_id.is_empty() || language_id.chars().any(char::is_control) {
|
||||||
|
return Err("LSP language keys must not be empty or contain control characters".into());
|
||||||
|
}
|
||||||
|
let preset = match language_id.as_str() {
|
||||||
|
"rust" => Some(("rust-analyzer", "rs")),
|
||||||
|
"python" => Some(("pylsp", "py")),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let mut options = match value {
|
||||||
|
Value::Bool(false) => continue,
|
||||||
|
Value::Bool(true) => {
|
||||||
|
if preset.is_none() {
|
||||||
|
return Err(format!(
|
||||||
|
"LSP language {language_id:?} has no built-in preset; provide command and extensions"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
serde_json::Map::new()
|
||||||
|
}
|
||||||
|
Value::Object(options) => options,
|
||||||
|
_ => {
|
||||||
|
return Err(format!(
|
||||||
|
"LSP language {language_id:?} must be true, false, or a configuration object"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for key in options.keys() {
|
||||||
|
if !matches!(
|
||||||
|
key.as_str(),
|
||||||
|
"command" | "extensions" | "initialization_options" | "settings"
|
||||||
|
) {
|
||||||
|
return Err(format!(
|
||||||
|
"LSP language {language_id:?} has unknown option {key:?}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let command = match options.remove("command") {
|
||||||
|
Some(value) => command(value, &language_id)?,
|
||||||
|
None => match preset {
|
||||||
|
Some((command, _)) => vec![command.into()],
|
||||||
|
None => {
|
||||||
|
return Err(format!(
|
||||||
|
"LSP language {language_id:?} requires command argv"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let extensions = match options.remove("extensions") {
|
||||||
|
Some(value) => extensions(value, &language_id)?,
|
||||||
|
None => match preset {
|
||||||
|
Some((_, extension)) => vec![extension.into()],
|
||||||
|
None => return Err(format!("LSP language {language_id:?} requires extensions")),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let initialization_options = object(
|
||||||
|
options.remove("initialization_options"),
|
||||||
|
&language_id,
|
||||||
|
"initialization_options",
|
||||||
|
)?;
|
||||||
|
let settings = object(options.remove("settings"), &language_id, "settings")?;
|
||||||
|
servers.push(ServerConfig {
|
||||||
|
language_id,
|
||||||
|
command,
|
||||||
|
extensions,
|
||||||
|
initialization_options,
|
||||||
|
settings,
|
||||||
|
});
|
||||||
|
if servers.len() > 16 {
|
||||||
|
return Err("LSP configuration supports at most 16 enabled languages".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(servers)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn deserialize<'de, D: serde::Deserializer<'de>>(
|
||||||
|
deserializer: D,
|
||||||
|
) -> Result<Vec<ServerConfig>, D::Error> {
|
||||||
|
resolve(Value::deserialize(deserializer)?).map_err(serde::de::Error::custom)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command(value: Value, language: &str) -> Result<Vec<String>, String> {
|
||||||
|
let error = || {
|
||||||
|
format!(
|
||||||
|
"LSP language {language:?} command must be nonempty argv with a nonempty executable and no NUL bytes"
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let Value::Array(values) = value else {
|
||||||
|
return Err(error());
|
||||||
|
};
|
||||||
|
if values.is_empty() {
|
||||||
|
return Err(error());
|
||||||
|
}
|
||||||
|
let mut command = Vec::with_capacity(values.len());
|
||||||
|
for value in values {
|
||||||
|
let Value::String(argument) = value else {
|
||||||
|
return Err(error());
|
||||||
|
};
|
||||||
|
if argument.contains('\0') || (command.is_empty() && argument.is_empty()) {
|
||||||
|
return Err(error());
|
||||||
|
}
|
||||||
|
command.push(argument);
|
||||||
|
}
|
||||||
|
Ok(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extensions(value: Value, language: &str) -> Result<Vec<String>, String> {
|
||||||
|
let error = || {
|
||||||
|
format!(
|
||||||
|
"LSP language {language:?} extensions must be a nonempty array of suffixes without dots, separators, whitespace, or control characters"
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let Value::Array(values) = value else {
|
||||||
|
return Err(error());
|
||||||
|
};
|
||||||
|
if values.is_empty() {
|
||||||
|
return Err(error());
|
||||||
|
}
|
||||||
|
let mut extensions = Vec::with_capacity(values.len());
|
||||||
|
for value in values {
|
||||||
|
let Value::String(extension) = value else {
|
||||||
|
return Err(error());
|
||||||
|
};
|
||||||
|
if extension.is_empty()
|
||||||
|
|| extension.chars().any(|character| {
|
||||||
|
matches!(character, '.' | '/' | '\\')
|
||||||
|
|| character.is_whitespace()
|
||||||
|
|| character.is_control()
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Err(error());
|
||||||
|
}
|
||||||
|
extensions.push(extension);
|
||||||
|
}
|
||||||
|
Ok(extensions)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn object(value: Option<Value>, language: &str, option: &str) -> Result<Value, String> {
|
||||||
|
match value {
|
||||||
|
None => Ok(Value::Object(serde_json::Map::new())),
|
||||||
|
Some(value) if value.is_object() => Ok(value),
|
||||||
|
Some(_) => Err(format!(
|
||||||
|
"LSP language {language:?} {option} must be a JSON object"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-2
@@ -36,8 +36,9 @@ Buffers: :edit PATH, :buffers, :buffer N, :bnext, :bprev, :bdelete[!] [N]
|
|||||||
Ruby config: $XDG_CONFIG_HOME/vamp/config.rb or ~/.config/vamp/config.rb
|
Ruby config: $XDG_CONFIG_HOME/vamp/config.rb or ~/.config/vamp/config.rb
|
||||||
Set VAMP_RUBY to choose the Ruby executable.
|
Set VAMP_RUBY to choose the Ruby executable.
|
||||||
|
|
||||||
Optional LSP Ruby plugin: config.plugin PATH, options: { servers: [...] }
|
Native LSP is disabled by default: config.lsp[\"rust\"] = true enables a preset
|
||||||
LSP commands when enabled: :lsp_definition, :lsp_diagnostics";
|
Custom languages: config.lsp[\"my-language\"] = { command: [...], extensions: [...] }
|
||||||
|
LSP commands: :lsp_definition, :lsp_diagnostics";
|
||||||
|
|
||||||
struct Options {
|
struct Options {
|
||||||
file: Option<PathBuf>,
|
file: Option<PathBuf>,
|
||||||
|
|||||||
+215
-3
@@ -19,6 +19,7 @@ const INVOKE_TIMEOUT: Duration = Duration::from_millis(500);
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub plugins: Vec<PluginConfig>,
|
pub plugins: Vec<PluginConfig>,
|
||||||
|
pub lsp: Vec<crate::lsp::ServerConfig>,
|
||||||
pub bindings: Vec<Binding>,
|
pub bindings: Vec<Binding>,
|
||||||
pub leader: String,
|
pub leader: String,
|
||||||
pub file_explorer_side: String,
|
pub file_explorer_side: String,
|
||||||
@@ -52,6 +53,7 @@ impl Default for Config {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
plugins: Vec::new(),
|
plugins: Vec::new(),
|
||||||
|
lsp: Vec::new(),
|
||||||
bindings: Vec::new(),
|
bindings: Vec::new(),
|
||||||
leader: "space".into(),
|
leader: "space".into(),
|
||||||
file_explorer_side: "right".into(),
|
file_explorer_side: "right".into(),
|
||||||
@@ -71,6 +73,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
|
|||||||
match response {
|
match response {
|
||||||
Response::Config {
|
Response::Config {
|
||||||
plugins,
|
plugins,
|
||||||
|
lsp,
|
||||||
bindings,
|
bindings,
|
||||||
leader,
|
leader,
|
||||||
file_explorer_side,
|
file_explorer_side,
|
||||||
@@ -92,6 +95,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
config.bindings = bindings;
|
config.bindings = bindings;
|
||||||
|
config.lsp = lsp;
|
||||||
config.leader = leader;
|
config.leader = leader;
|
||||||
config.file_explorer_side = file_explorer_side;
|
config.file_explorer_side = file_explorer_side;
|
||||||
config.file_picker_view = file_picker_view;
|
config.file_picker_view = file_picker_view;
|
||||||
@@ -375,6 +379,8 @@ enum Response {
|
|||||||
Config {
|
Config {
|
||||||
version: u8,
|
version: u8,
|
||||||
plugins: Vec<PluginConfig>,
|
plugins: Vec<PluginConfig>,
|
||||||
|
#[serde(deserialize_with = "crate::lsp_config::deserialize")]
|
||||||
|
lsp: Vec<crate::lsp::ServerConfig>,
|
||||||
bindings: Vec<Binding>,
|
bindings: Vec<Binding>,
|
||||||
leader: String,
|
leader: String,
|
||||||
file_explorer_side: String,
|
file_explorer_side: String,
|
||||||
@@ -879,6 +885,7 @@ end
|
|||||||
let defaults = Config::default();
|
let defaults = Config::default();
|
||||||
assert_eq!(defaults.leader, "space");
|
assert_eq!(defaults.leader, "space");
|
||||||
assert!(defaults.plugins.is_empty() && defaults.bindings.is_empty());
|
assert!(defaults.plugins.is_empty() && defaults.bindings.is_empty());
|
||||||
|
assert!(defaults.lsp.is_empty());
|
||||||
let path = fixture.file("config.rb", "Vamp.configure { |_| }");
|
let path = fixture.file("config.rb", "Vamp.configure { |_| }");
|
||||||
assert_eq!(load_config(&path).unwrap().leader, "space");
|
assert_eq!(load_config(&path).unwrap().leader, "space");
|
||||||
for (source, key) in [
|
for (source, key) in [
|
||||||
@@ -900,14 +907,219 @@ end
|
|||||||
assert!(load_config(&path).unwrap_err().contains(error), "{value}");
|
assert!(load_config(&path).unwrap_err().contains(error), "{value}");
|
||||||
}
|
}
|
||||||
for frame in [
|
for frame in [
|
||||||
r#"{"type":"config","version":1,"plugins":[],"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system"}"#,
|
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system"}"#,
|
||||||
r#"{"type":"config","version":1,"plugins":[],"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system"}"#,
|
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system"}"#,
|
||||||
r#"{"type":"config","version":1,"plugins":[],"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system"}"#,
|
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system"}"#,
|
||||||
] {
|
] {
|
||||||
assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}");
|
assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lsp_configuration_is_disabled_unless_explicitly_enabled() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
for source in [
|
||||||
|
"Vamp.configure { |_| }",
|
||||||
|
r#"Vamp.configure do |config|
|
||||||
|
config.lsp["rust"] = false
|
||||||
|
config.lsp["python"] = false
|
||||||
|
config.lsp["not-a-preset"] = false
|
||||||
|
end"#,
|
||||||
|
] {
|
||||||
|
let path = fixture.file("config.rb", source);
|
||||||
|
let config = load_config(&path).unwrap();
|
||||||
|
assert!(config.lsp.is_empty());
|
||||||
|
assert!(config.plugins.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lsp_presets_custom_languages_and_overrides_reach_native_configuration() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let path = fixture.file(
|
||||||
|
"config.rb",
|
||||||
|
r#"Vamp.configure do |config|
|
||||||
|
config.lsp["rust"] = true
|
||||||
|
config.lsp["python"] = true
|
||||||
|
end"#,
|
||||||
|
);
|
||||||
|
let config = load_config(&path).unwrap();
|
||||||
|
assert_eq!(config.lsp.len(), 2);
|
||||||
|
for (language, command, extension) in
|
||||||
|
[("rust", "rust-analyzer", "rs"), ("python", "pylsp", "py")]
|
||||||
|
{
|
||||||
|
let server = config
|
||||||
|
.lsp
|
||||||
|
.iter()
|
||||||
|
.find(|server| server.language_id == language)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(server.command, [command]);
|
||||||
|
assert_eq!(server.extensions, [extension]);
|
||||||
|
assert_eq!(server.initialization_options, serde_json::json!({}));
|
||||||
|
assert_eq!(server.settings, serde_json::json!({}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = fixture.file(
|
||||||
|
"config.rb",
|
||||||
|
r#"Vamp.configure do |config|
|
||||||
|
config.lsp["rust"] = {
|
||||||
|
extensions: ["rs", "ron"],
|
||||||
|
initialization_options: {cargo: {features: ["α"]}},
|
||||||
|
settings: {"rust-analyzer" => {check: {command: "clippy"}}}
|
||||||
|
}
|
||||||
|
config.lsp["python"] = {command: ["custom pylsp", "--stdio"]}
|
||||||
|
config.lsp["my-language.c++"] = {
|
||||||
|
command: ["custom server", "--flag", ""],
|
||||||
|
extensions: ["custom", "لغة"],
|
||||||
|
settings: {nested: {enabled: true, limit: 3, unset: nil}}
|
||||||
|
}
|
||||||
|
end"#,
|
||||||
|
);
|
||||||
|
let config = load_config(&path).unwrap();
|
||||||
|
assert_eq!(config.lsp.len(), 3);
|
||||||
|
let rust = config
|
||||||
|
.lsp
|
||||||
|
.iter()
|
||||||
|
.find(|server| server.language_id == "rust")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(rust.command, ["rust-analyzer"]);
|
||||||
|
assert_eq!(rust.extensions, ["rs", "ron"]);
|
||||||
|
assert_eq!(
|
||||||
|
rust.initialization_options,
|
||||||
|
serde_json::json!({"cargo": {"features": ["α"]}})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rust.settings,
|
||||||
|
serde_json::json!({"rust-analyzer": {"check": {"command": "clippy"}}})
|
||||||
|
);
|
||||||
|
let python = config
|
||||||
|
.lsp
|
||||||
|
.iter()
|
||||||
|
.find(|server| server.language_id == "python")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(python.command, ["custom pylsp", "--stdio"]);
|
||||||
|
assert_eq!(python.extensions, ["py"]);
|
||||||
|
assert_eq!(python.initialization_options, serde_json::json!({}));
|
||||||
|
assert_eq!(python.settings, serde_json::json!({}));
|
||||||
|
let custom = config
|
||||||
|
.lsp
|
||||||
|
.iter()
|
||||||
|
.find(|server| server.language_id == "my-language.c++")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(custom.command, ["custom server", "--flag", ""]);
|
||||||
|
assert_eq!(custom.extensions, ["custom", "لغة"]);
|
||||||
|
assert_eq!(custom.initialization_options, serde_json::json!({}));
|
||||||
|
assert_eq!(
|
||||||
|
custom.settings,
|
||||||
|
serde_json::json!({"nested": {"enabled": true, "limit": 3, "unset": null}})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lsp_configuration_rejects_invalid_keys_values_and_json_options() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
for assignment in [
|
||||||
|
r#"config.lsp[:rust] = true"#,
|
||||||
|
r#"config.lsp[7] = false"#,
|
||||||
|
r#"config.lsp[""] = false"#,
|
||||||
|
r#"config.lsp["bad\nkey"] = false"#,
|
||||||
|
r#"config.lsp["\xFF".b] = false"#,
|
||||||
|
r#"config.lsp["rust"] = nil"#,
|
||||||
|
r#"config.lsp["rust"] = []"#,
|
||||||
|
r#"config.lsp["rust"] = "yes""#,
|
||||||
|
r#"config.lsp["custom"] = {}"#,
|
||||||
|
r#"config.lsp["custom"] = {command: ["server"]}"#,
|
||||||
|
r#"config.lsp["custom"] = {extensions: ["custom"]}"#,
|
||||||
|
r#"config.lsp["rust"] = {command: "server"}"#,
|
||||||
|
r#"config.lsp["rust"] = {command: []}"#,
|
||||||
|
r#"config.lsp["rust"] = {command: [""]}"#,
|
||||||
|
r#"config.lsp["rust"] = {command: ["server", 7]}"#,
|
||||||
|
r#"config.lsp["rust"] = {command: ["server", "bad\0argument"]}"#,
|
||||||
|
r#"config.lsp["rust"] = {extensions: "rs"}"#,
|
||||||
|
r#"config.lsp["rust"] = {extensions: []}"#,
|
||||||
|
r#"config.lsp["rust"] = {extensions: [""]}"#,
|
||||||
|
r#"config.lsp["rust"] = {extensions: [7]}"#,
|
||||||
|
r#"config.lsp["rust"] = {extensions: [".rs"]}"#,
|
||||||
|
r#"config.lsp["rust"] = {extensions: ["path/rs"]}"#,
|
||||||
|
r#"config.lsp["rust"] = {extensions: ["path\\rs"]}"#,
|
||||||
|
r#"config.lsp["rust"] = {extensions: ["r s"]}"#,
|
||||||
|
r#"config.lsp["rust"] = {extensions: ["r\u0001s"]}"#,
|
||||||
|
r#"config.lsp["rust"] = {initialization_options: []}"#,
|
||||||
|
r#"config.lsp["rust"] = {settings: nil}"#,
|
||||||
|
r#"config.lsp["rust"] = {settings: {bad: Object.new}}"#,
|
||||||
|
r#"config.lsp["rust"] = {settings: {bad: Float::NAN}}"#,
|
||||||
|
r#"config.lsp["rust"] = {settings: {7 => true}}"#,
|
||||||
|
r#"config.lsp["rust"] = {unknown: true}"#,
|
||||||
|
] {
|
||||||
|
let path = fixture.file(
|
||||||
|
"invalid.rb",
|
||||||
|
&format!("Vamp.configure {{ |config| {assignment} }}"),
|
||||||
|
);
|
||||||
|
assert!(load_config(&path).is_err(), "{assignment}");
|
||||||
|
}
|
||||||
|
let path = fixture.file(
|
||||||
|
"invalid.rb",
|
||||||
|
r#"Vamp.configure { |config| config.lsp["unlisted"] = true }"#,
|
||||||
|
);
|
||||||
|
let error = load_config(&path).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
error.contains("unlisted") && error.contains("preset"),
|
||||||
|
"{error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lsp_wire_configuration_is_validated_before_being_consumed() {
|
||||||
|
let mut frame = serde_json::json!({
|
||||||
|
"version": 1, "type": "config", "plugins": [], "lsp": {},
|
||||||
|
"bindings": [], "leader": "space", "file_explorer_side": "right",
|
||||||
|
"file_picker_view": "tree", "file_explorer_view": "tree",
|
||||||
|
"clipboard_mode": "system"
|
||||||
|
});
|
||||||
|
assert!(matches!(
|
||||||
|
serde_json::from_value::<Response>(frame.clone()).unwrap(),
|
||||||
|
Response::Config { lsp, .. } if lsp.is_empty()
|
||||||
|
));
|
||||||
|
frame["lsp"] = serde_json::json!({"rust": true});
|
||||||
|
assert!(matches!(
|
||||||
|
serde_json::from_value::<Response>(frame.clone()).unwrap(),
|
||||||
|
Response::Config { lsp, .. }
|
||||||
|
if lsp.len() == 1 && lsp[0].command == ["rust-analyzer"]
|
||||||
|
));
|
||||||
|
for value in [
|
||||||
|
serde_json::json!(null),
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!({"": false}),
|
||||||
|
serde_json::json!({"bad\u{0085}key": false}),
|
||||||
|
serde_json::json!({"rust": null}),
|
||||||
|
serde_json::json!({"unknown": true}),
|
||||||
|
serde_json::json!({"custom": {}}),
|
||||||
|
serde_json::json!({"rust": {"command": ["tool", "bad\0argument"]}}),
|
||||||
|
serde_json::json!({"rust": {"extensions": ["bad\nextension"]}}),
|
||||||
|
serde_json::json!({"rust": {"initialization_options": []}}),
|
||||||
|
serde_json::json!({"rust": {"settings": "not-an-object"}}),
|
||||||
|
serde_json::json!({"rust": {"unexpected": true}}),
|
||||||
|
] {
|
||||||
|
frame["lsp"] = value;
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<Response>(frame.clone()).is_err(),
|
||||||
|
"{frame}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let languages: serde_json::Map<String, serde_json::Value> = (0..17)
|
||||||
|
.map(|index| {
|
||||||
|
(
|
||||||
|
format!("custom-{index}"),
|
||||||
|
serde_json::json!({"command": ["server"], "extensions": ["custom"]}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
frame["lsp"] = serde_json::Value::Object(languages);
|
||||||
|
assert!(serde_json::from_value::<Response>(frame.clone()).is_err());
|
||||||
|
frame.as_object_mut().unwrap().remove("lsp");
|
||||||
|
assert!(serde_json::from_value::<Response>(frame).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn crash_hang_callback_failure_and_fresh_host_recovery() {
|
fn crash_hang_callback_failure_and_fresh_host_recovery() {
|
||||||
let fixture = Fixture::new();
|
let fixture = Fixture::new();
|
||||||
|
|||||||
+19
-1
@@ -81,8 +81,11 @@ module Vamp
|
|||||||
Mode.private_class_method :new
|
Mode.private_class_method :new
|
||||||
|
|
||||||
class Config
|
class Config
|
||||||
|
attr_reader :lsp
|
||||||
|
|
||||||
def initialize
|
def initialize
|
||||||
@plugins = []
|
@plugins = []
|
||||||
|
@lsp = {}
|
||||||
@bindings = []
|
@bindings = []
|
||||||
@leader = "space"
|
@leader = "space"
|
||||||
@file_explorer_side = "right"
|
@file_explorer_side = "right"
|
||||||
@@ -159,8 +162,23 @@ module Vamp
|
|||||||
end
|
end
|
||||||
|
|
||||||
def response
|
def response
|
||||||
|
lsp = @lsp.to_h do |language, value|
|
||||||
|
language = Vamp.string!(language, "LSP language key")
|
||||||
|
if language.empty? || language.match?(/[[:cntrl:]]/)
|
||||||
|
raise ArgumentError, "LSP language keys must not be empty or contain control characters"
|
||||||
|
end
|
||||||
|
value = case value
|
||||||
|
when TrueClass, FalseClass
|
||||||
|
value
|
||||||
|
when Hash
|
||||||
|
Vamp.json_value!(value)
|
||||||
|
else
|
||||||
|
raise TypeError, "LSP language #{language.inspect} must be true, false, or a Hash"
|
||||||
|
end
|
||||||
|
[language, value]
|
||||||
|
end
|
||||||
{ "version" => 1, "type" => "config", "plugins" => @plugins,
|
{ "version" => 1, "type" => "config", "plugins" => @plugins,
|
||||||
"bindings" => @bindings, "leader" => @leader,
|
"lsp" => lsp, "bindings" => @bindings, "leader" => @leader,
|
||||||
"file_explorer_side" => @file_explorer_side,
|
"file_explorer_side" => @file_explorer_side,
|
||||||
"file_picker_view" => @file_picker_view,
|
"file_picker_view" => @file_picker_view,
|
||||||
"file_explorer_view" => @file_explorer_view,
|
"file_explorer_view" => @file_explorer_view,
|
||||||
|
|||||||
+55
-3
@@ -5,7 +5,8 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
editor::{Editor, Effect, Input, Invocation},
|
editor::{Cursor, Editor, Effect, Input, Invocation, Mode},
|
||||||
|
lsp::Lsp,
|
||||||
plugin::{
|
plugin::{
|
||||||
DocumentEvent, DocumentEventKind, DocumentSnapshot, PluginEvent, PluginUpdate,
|
DocumentEvent, DocumentEventKind, DocumentSnapshot, PluginEvent, PluginUpdate,
|
||||||
TextPosition, UpdateEffect,
|
TextPosition, UpdateEffect,
|
||||||
@@ -37,6 +38,9 @@ pub struct EditorSession {
|
|||||||
plugins: BTreeMap<String, RubyPlugin>,
|
plugins: BTreeMap<String, RubyPlugin>,
|
||||||
documents: BTreeMap<usize, TrackedDocument>,
|
documents: BTreeMap<usize, TrackedDocument>,
|
||||||
requests: BTreeMap<String, AsyncRequests>,
|
requests: BTreeMap<String, AsyncRequests>,
|
||||||
|
lsp: Lsp,
|
||||||
|
lsp_request_id: u64,
|
||||||
|
lsp_source_cursor: Option<Cursor>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EditorSession {
|
impl EditorSession {
|
||||||
@@ -117,6 +121,9 @@ impl EditorSession {
|
|||||||
plugins,
|
plugins,
|
||||||
documents: BTreeMap::new(),
|
documents: BTreeMap::new(),
|
||||||
requests: BTreeMap::new(),
|
requests: BTreeMap::new(),
|
||||||
|
lsp: Lsp::new(config.lsp),
|
||||||
|
lsp_request_id: 0,
|
||||||
|
lsp_source_cursor: None,
|
||||||
};
|
};
|
||||||
session.synchronize_documents();
|
session.synchronize_documents();
|
||||||
Ok(session)
|
Ok(session)
|
||||||
@@ -145,6 +152,31 @@ impl EditorSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn dispatch(&mut self, invocation: Invocation) {
|
fn dispatch(&mut self, invocation: Invocation) {
|
||||||
|
if invocation.plugin == "core" {
|
||||||
|
let result = match invocation.command.as_str() {
|
||||||
|
"lsp_definition" => {
|
||||||
|
if let Some(id) = self.lsp_request_id.checked_add(1) {
|
||||||
|
self.lsp_request_id = id;
|
||||||
|
let context = self.editor.context();
|
||||||
|
let source = InvocationSource {
|
||||||
|
buffer_id: context.buffer_id,
|
||||||
|
revision: context.revision,
|
||||||
|
};
|
||||||
|
self.requests.entry("core".into()).or_default().latest = Some((id, source));
|
||||||
|
self.lsp_source_cursor = Some(self.editor.cursor());
|
||||||
|
self.lsp.definition(&context, id)
|
||||||
|
} else {
|
||||||
|
Err("LSP request IDs exhausted".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"lsp_diagnostics" => self.lsp.diagnostics(self.editor.buffer_id()),
|
||||||
|
_ => unreachable!("session-dispatched core command"),
|
||||||
|
};
|
||||||
|
if let Err(error) = result {
|
||||||
|
self.editor.set_message(format!("LSP: {error}"));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
let context = self.editor.context();
|
let context = self.editor.context();
|
||||||
let result = match self.plugins.get_mut(&invocation.plugin) {
|
let result = match self.plugins.get_mut(&invocation.plugin) {
|
||||||
Some(plugin) if plugin.is_async() => {
|
Some(plugin) if plugin.is_async() => {
|
||||||
@@ -178,7 +210,7 @@ impl EditorSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn synchronize_documents(&mut self) -> bool {
|
fn synchronize_documents(&mut self) -> bool {
|
||||||
if !self.plugins.values().any(RubyPlugin::is_async) {
|
if !self.lsp.is_enabled() && !self.plugins.values().any(RubyPlugin::is_async) {
|
||||||
self.documents.clear();
|
self.documents.clear();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -250,6 +282,13 @@ impl EditorSession {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
let mut failures = Vec::new();
|
let mut failures = Vec::new();
|
||||||
|
let mut changed = false;
|
||||||
|
for event in &events {
|
||||||
|
if let Err(error) = self.lsp.notify_document(event) {
|
||||||
|
self.editor.set_message(format!("LSP: {error}"));
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
for (owner, plugin) in &mut self.plugins {
|
for (owner, plugin) in &mut self.plugins {
|
||||||
if !plugin.is_async() {
|
if !plugin.is_async() {
|
||||||
continue;
|
continue;
|
||||||
@@ -261,7 +300,7 @@ impl EditorSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let changed = !failures.is_empty();
|
changed |= !failures.is_empty();
|
||||||
for (owner, error) in failures {
|
for (owner, error) in failures {
|
||||||
self.retire(&owner, &error);
|
self.retire(&owner, &error);
|
||||||
}
|
}
|
||||||
@@ -282,6 +321,9 @@ impl EditorSession {
|
|||||||
) -> bool {
|
) -> bool {
|
||||||
self.editor.buffer_id() == source.buffer_id
|
self.editor.buffer_id() == source.buffer_id
|
||||||
&& self.source_is_current(source)
|
&& self.source_is_current(source)
|
||||||
|
&& (owner != "core"
|
||||||
|
|| (self.lsp_source_cursor == Some(self.editor.cursor())
|
||||||
|
&& self.editor.mode() == Mode::Normal))
|
||||||
&& self
|
&& self
|
||||||
.requests
|
.requests
|
||||||
.get(owner)
|
.get(owner)
|
||||||
@@ -406,6 +448,16 @@ impl EditorSession {
|
|||||||
/// Drains ready plugin work without waiting for command or server responses.
|
/// Drains ready plugin work without waiting for command or server responses.
|
||||||
pub fn poll(&mut self) -> bool {
|
pub fn poll(&mut self) -> bool {
|
||||||
let mut changed = self.synchronize_documents();
|
let mut changed = self.synchronize_documents();
|
||||||
|
for update in self.lsp.poll() {
|
||||||
|
match self.apply_update("core", update) {
|
||||||
|
Ok(applied) => changed |= applied,
|
||||||
|
Err(error) => {
|
||||||
|
self.editor.clear_plugin_diagnostics("core");
|
||||||
|
self.editor.set_message(format!("LSP: {error}"));
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let mut ready = Vec::new();
|
let mut ready = Vec::new();
|
||||||
for (owner, plugin) in &mut self.plugins {
|
for (owner, plugin) in &mut self.plugins {
|
||||||
if plugin.is_async() {
|
if plugin.is_async() {
|
||||||
|
|||||||
Executable → Regular
+102
-9
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
fs,
|
fs,
|
||||||
path::{Path, PathBuf},
|
path::PathBuf,
|
||||||
sync::atomic::{AtomicU64, Ordering},
|
sync::atomic::{AtomicU64, Ordering},
|
||||||
thread,
|
thread,
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
@@ -17,7 +17,7 @@ use vamp::{
|
|||||||
|
|
||||||
static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0);
|
static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
// A real subprocess speaking framed LSP, not a plugin echoing generic effects.
|
// A real subprocess speaking framed LSP to the native client.
|
||||||
// Files are explicit test-controlled gates; no wall-clock ordering assumptions.
|
// Files are explicit test-controlled gates; no wall-clock ordering assumptions.
|
||||||
const SERVER: &str = r#"
|
const SERVER: &str = r#"
|
||||||
require 'json'
|
require 'json'
|
||||||
@@ -213,13 +213,14 @@ impl Fixture {
|
|||||||
self.alternate
|
self.alternate
|
||||||
])
|
])
|
||||||
};
|
};
|
||||||
let options = json!({"servers": [{
|
let options = json!({
|
||||||
"language_id": "rust", "extensions": ["rs"], "command": command,
|
"extensions": if mode == "irrelevant" { vec!["myl"] } else { vec!["rs"] },
|
||||||
}]});
|
"command": command,
|
||||||
let plugin = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/plugins/lsp.rb");
|
"initialization_options": {"fixture": true},
|
||||||
|
"settings": {"rust-analyzer": {"cargo": {"allFeatures": true}}},
|
||||||
|
});
|
||||||
let config = format!(
|
let config = format!(
|
||||||
"Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.plugin {}, options: JSON.parse({})\nend\n",
|
"Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.lsp['fixture-language'] = JSON.parse({})\nend\n",
|
||||||
serde_json::to_string(&plugin).unwrap(),
|
|
||||||
serde_json::to_string(&options.to_string()).unwrap(),
|
serde_json::to_string(&options.to_string()).unwrap(),
|
||||||
);
|
);
|
||||||
let config_path = self.root.join("config.rb");
|
let config_path = self.root.join("config.rb");
|
||||||
@@ -323,6 +324,10 @@ fn lsp_framing_lifecycle_diagnostics_and_unicode_dirty_definition() {
|
|||||||
initialize["params"]["capabilities"]["general"]["positionEncodings"],
|
initialize["params"]["capabilities"]["general"]["positionEncodings"],
|
||||||
json!(["utf-16"])
|
json!(["utf-16"])
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
initialize["params"]["initializationOptions"],
|
||||||
|
json!({"fixture": true})
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
initialize["params"]["capabilities"]["textDocument"]["publishDiagnostics"]["versionSupport"],
|
initialize["params"]["capabilities"]["textDocument"]["publishDiagnostics"]["versionSupport"],
|
||||||
true
|
true
|
||||||
@@ -340,6 +345,10 @@ fn lsp_framing_lifecycle_diagnostics_and_unicode_dirty_definition() {
|
|||||||
.position(|frame| frame["method"] == "initialized")
|
.position(|frame| frame["method"] == "initialized")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(initialized_index < open_index);
|
assert!(initialized_index < open_index);
|
||||||
|
assert_eq!(
|
||||||
|
frames[open_index]["params"]["textDocument"]["languageId"],
|
||||||
|
"fixture-language"
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
frames[open_index]["params"]["textDocument"]["text"],
|
frames[open_index]["params"]["textDocument"]["text"],
|
||||||
"A😀e\u{301}Z\n"
|
"A😀e\u{301}Z\n"
|
||||||
@@ -368,7 +377,7 @@ fn lsp_framing_lifecycle_diagnostics_and_unicode_dirty_definition() {
|
|||||||
.iter()
|
.iter()
|
||||||
.find(|frame| frame["id"] == "settings")
|
.find(|frame| frame["id"] == "settings")
|
||||||
.unwrap()["result"],
|
.unwrap()["result"],
|
||||||
json!([null, null])
|
json!([{"cargo": {"allFeatures": true}}, null])
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
frames.iter().find(|frame| frame["id"] == 0).unwrap()["error"]["code"],
|
frames.iter().find(|frame| frame["id"] == 0).unwrap()["error"]["code"],
|
||||||
@@ -698,3 +707,87 @@ fn lsp_optional_configuration_numeric_sync_and_unversioned_diagnostics() {
|
|||||||
assert_eq!(fixture.method_count("textDocument/didChange"), 0);
|
assert_eq!(fixture.method_count("textDocument/didChange"), 0);
|
||||||
assert_eq!(fixture.method_count("textDocument/didSave"), 0);
|
assert_eq!(fixture.method_count("textDocument/didSave"), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lsp_definition_reuses_dirty_targets_without_an_enabled_target_language() {
|
||||||
|
let mut fixture = Fixture::new();
|
||||||
|
let target = fixture.target.with_extension("txt");
|
||||||
|
fs::rename(&fixture.target, &target).unwrap();
|
||||||
|
fixture.target = target;
|
||||||
|
let mut session = fixture.open("basic");
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session.editor().diagnostics().count() == 2
|
||||||
|
});
|
||||||
|
let target_id = edit_target(&mut session, &fixture);
|
||||||
|
session.execute("lsp_definition");
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session.editor().buffer_id() == target_id
|
||||||
|
});
|
||||||
|
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 1 });
|
||||||
|
assert_eq!(session.editor().lines(), &["😀e\u{301}disk", ""]);
|
||||||
|
assert!(session.editor().dirty());
|
||||||
|
assert_eq!(fs::read_to_string(&fixture.target).unwrap(), "disk\n");
|
||||||
|
assert_eq!(fixture.method_count("textDocument/didOpen"), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lsp_definition_does_not_move_a_caret_that_has_moved_since_the_request() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let mut session = fixture.open("stale");
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session.editor().diagnostics().count() == 2
|
||||||
|
});
|
||||||
|
session.execute("lsp_definition");
|
||||||
|
until(&mut session, &fixture, |_| {
|
||||||
|
fixture.method_count("textDocument/definition") == 1
|
||||||
|
});
|
||||||
|
session.handle(Input::Right);
|
||||||
|
fixture.release();
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session
|
||||||
|
.editor()
|
||||||
|
.message()
|
||||||
|
.contains("stale response complete")
|
||||||
|
});
|
||||||
|
assert_eq!(session.editor().buffer_id(), 1);
|
||||||
|
assert_eq!(session.editor().buffer_count(), 1);
|
||||||
|
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lsp_only_starts_an_explicitly_configured_server_when_a_matching_buffer_opens() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let mut session = fixture.open("irrelevant");
|
||||||
|
session.execute("lsp_diagnostics");
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session.editor().message().contains("no running server")
|
||||||
|
});
|
||||||
|
assert!(!fixture.log.exists());
|
||||||
|
let custom = fixture.root.join("custom.myl");
|
||||||
|
fs::write(&custom, "custom language\n").unwrap();
|
||||||
|
session.execute(&format!("edit {}", custom.display()));
|
||||||
|
until(&mut session, &fixture, |_| {
|
||||||
|
fixture.method_count("textDocument/didOpen") == 1
|
||||||
|
});
|
||||||
|
let frames = fixture.frames();
|
||||||
|
let open = frames
|
||||||
|
.iter()
|
||||||
|
.find(|frame| frame["method"] == "textDocument/didOpen")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
open["params"]["textDocument"]["languageId"],
|
||||||
|
"fixture-language"
|
||||||
|
);
|
||||||
|
assert_eq!(open["params"]["textDocument"]["text"], "custom language\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lsp_session_shutdown_finishes_the_server_protocol_before_returning() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let mut session = fixture.open("basic");
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session.editor().diagnostics().count() == 2
|
||||||
|
});
|
||||||
|
drop(session);
|
||||||
|
assert_eq!(fixture.method_count("shutdown"), 1);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user