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
|
||||
|
||||
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.
|
||||
- `files`: owns the gitignore-aware directory/file catalog, authoritative OS paths, compact tree projection, query filtering, browse/search focus, expansion/selection restoration, navigation, and scan/error state shared by floating and docked presentations.
|
||||
- `clipboard`: owns native plain-text provider selection and bounded subprocess I/O; it never computes selections or editing offsets.
|
||||
- `syntax`: owns Tree-sitter grammar detection, per-buffer incremental trees, bundled highlight/local/injection queries, and semantic byte spans; it has no terminal color/geometry or editing dependency.
|
||||
- `plugin`: owns generic document snapshots/events, zero-based grapheme positions, diagnostics, and versioned asynchronous update contracts. It has no LSP protocol types.
|
||||
- `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.
|
||||
- `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.
|
||||
@@ -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 `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.
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
### 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
|
||||
config.plugin "plugins/lsp.rb", options: {
|
||||
servers: [
|
||||
{ language_id: "rust", extensions: ["rs"], command: ["rust-analyzer"] },
|
||||
{ language_id: "python", extensions: ["py"], command: ["pylsp"] }
|
||||
]
|
||||
Vamp.configure do |config|
|
||||
config.lsp["rust"] = true # rust-analyzer, extensions: ["rs"]
|
||||
config.lsp["python"] = false # disabled (also the default when absent)
|
||||
|
||||
config.lsp["my-language"] = {
|
||||
command: ["my-language-server", "--stdio"],
|
||||
extensions: ["myl"],
|
||||
initialization_options: {},
|
||||
settings: { "my-language-server" => { "exampleOption" => true } }
|
||||
}
|
||||
config.bind "leader d", "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.
|
||||
|
||||
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
|
||||
|
||||
@@ -381,7 +393,7 @@ JSON uses Ruby's standard library. Protocol Buffers is not installed or required
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
@@ -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.
|
||||
|
||||
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+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"] }]
|
||||
# Native LSP is disabled by default; install a server and explicitly opt in.
|
||||
# config.lsp["rust"] = true # built-in rust-analyzer preset
|
||||
# config.lsp["my-language"] = {
|
||||
# command: ["my-language-server", "--stdio"], extensions: ["myl"],
|
||||
# initialization_options: {}, settings: {}
|
||||
# }
|
||||
# config.bind "leader d", "lsp_definition"
|
||||
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(),
|
||||
required_capabilities: Vec::new(),
|
||||
commands: [
|
||||
"w", "q", "q!", "wq", "plugins", "edit", "buffers", "buffer", "bnext",
|
||||
"bprev", "bdelete", "bdelete!",
|
||||
"w",
|
||||
"q",
|
||||
"q!",
|
||||
"wq",
|
||||
"plugins",
|
||||
"edit",
|
||||
"buffers",
|
||||
"buffer",
|
||||
"bnext",
|
||||
"bprev",
|
||||
"bdelete",
|
||||
"bdelete!",
|
||||
"lsp_definition",
|
||||
"lsp_diagnostics",
|
||||
]
|
||||
.into_iter()
|
||||
.map(str::to_owned)
|
||||
@@ -2384,6 +2396,16 @@ impl Editor {
|
||||
});
|
||||
}
|
||||
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" => {
|
||||
if self.save(if args.is_empty() {
|
||||
None
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
pub mod clipboard;
|
||||
pub mod editor;
|
||||
pub mod files;
|
||||
pub mod lsp;
|
||||
mod lsp_config;
|
||||
pub mod plugin;
|
||||
pub mod ruby;
|
||||
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
|
||||
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";
|
||||
Native LSP is disabled by default: config.lsp[\"rust\"] = true enables a preset
|
||||
Custom languages: config.lsp[\"my-language\"] = { command: [...], extensions: [...] }
|
||||
LSP commands: :lsp_definition, :lsp_diagnostics";
|
||||
|
||||
struct Options {
|
||||
file: Option<PathBuf>,
|
||||
|
||||
+215
-3
@@ -19,6 +19,7 @@ const INVOKE_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
#[derive(Debug)]
|
||||
pub struct Config {
|
||||
pub plugins: Vec<PluginConfig>,
|
||||
pub lsp: Vec<crate::lsp::ServerConfig>,
|
||||
pub bindings: Vec<Binding>,
|
||||
pub leader: String,
|
||||
pub file_explorer_side: String,
|
||||
@@ -52,6 +53,7 @@ impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
plugins: Vec::new(),
|
||||
lsp: Vec::new(),
|
||||
bindings: Vec::new(),
|
||||
leader: "space".into(),
|
||||
file_explorer_side: "right".into(),
|
||||
@@ -71,6 +73,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
|
||||
match response {
|
||||
Response::Config {
|
||||
plugins,
|
||||
lsp,
|
||||
bindings,
|
||||
leader,
|
||||
file_explorer_side,
|
||||
@@ -92,6 +95,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
|
||||
});
|
||||
}
|
||||
config.bindings = bindings;
|
||||
config.lsp = lsp;
|
||||
config.leader = leader;
|
||||
config.file_explorer_side = file_explorer_side;
|
||||
config.file_picker_view = file_picker_view;
|
||||
@@ -375,6 +379,8 @@ enum Response {
|
||||
Config {
|
||||
version: u8,
|
||||
plugins: Vec<PluginConfig>,
|
||||
#[serde(deserialize_with = "crate::lsp_config::deserialize")]
|
||||
lsp: Vec<crate::lsp::ServerConfig>,
|
||||
bindings: Vec<Binding>,
|
||||
leader: String,
|
||||
file_explorer_side: String,
|
||||
@@ -879,6 +885,7 @@ end
|
||||
let defaults = Config::default();
|
||||
assert_eq!(defaults.leader, "space");
|
||||
assert!(defaults.plugins.is_empty() && defaults.bindings.is_empty());
|
||||
assert!(defaults.lsp.is_empty());
|
||||
let path = fixture.file("config.rb", "Vamp.configure { |_| }");
|
||||
assert_eq!(load_config(&path).unwrap().leader, "space");
|
||||
for (source, key) in [
|
||||
@@ -900,14 +907,219 @@ end
|
||||
assert!(load_config(&path).unwrap_err().contains(error), "{value}");
|
||||
}
|
||||
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":[],"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":[],"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":[],"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}");
|
||||
}
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn crash_hang_callback_failure_and_fresh_host_recovery() {
|
||||
let fixture = Fixture::new();
|
||||
|
||||
+19
-1
@@ -81,8 +81,11 @@ module Vamp
|
||||
Mode.private_class_method :new
|
||||
|
||||
class Config
|
||||
attr_reader :lsp
|
||||
|
||||
def initialize
|
||||
@plugins = []
|
||||
@lsp = {}
|
||||
@bindings = []
|
||||
@leader = "space"
|
||||
@file_explorer_side = "right"
|
||||
@@ -159,8 +162,23 @@ module Vamp
|
||||
end
|
||||
|
||||
def response
|
||||
lsp = @lsp.to_h do |language, value|
|
||||
language = Vamp.string!(language, "LSP language key")
|
||||
if language.empty? || language.match?(/[[:cntrl:]]/)
|
||||
raise ArgumentError, "LSP language keys must not be empty or contain control characters"
|
||||
end
|
||||
value = case value
|
||||
when TrueClass, FalseClass
|
||||
value
|
||||
when Hash
|
||||
Vamp.json_value!(value)
|
||||
else
|
||||
raise TypeError, "LSP language #{language.inspect} must be true, false, or a Hash"
|
||||
end
|
||||
[language, value]
|
||||
end
|
||||
{ "version" => 1, "type" => "config", "plugins" => @plugins,
|
||||
"bindings" => @bindings, "leader" => @leader,
|
||||
"lsp" => lsp, "bindings" => @bindings, "leader" => @leader,
|
||||
"file_explorer_side" => @file_explorer_side,
|
||||
"file_picker_view" => @file_picker_view,
|
||||
"file_explorer_view" => @file_explorer_view,
|
||||
|
||||
+55
-3
@@ -5,7 +5,8 @@ use std::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
editor::{Editor, Effect, Input, Invocation},
|
||||
editor::{Cursor, Editor, Effect, Input, Invocation, Mode},
|
||||
lsp::Lsp,
|
||||
plugin::{
|
||||
DocumentEvent, DocumentEventKind, DocumentSnapshot, PluginEvent, PluginUpdate,
|
||||
TextPosition, UpdateEffect,
|
||||
@@ -37,6 +38,9 @@ pub struct EditorSession {
|
||||
plugins: BTreeMap<String, RubyPlugin>,
|
||||
documents: BTreeMap<usize, TrackedDocument>,
|
||||
requests: BTreeMap<String, AsyncRequests>,
|
||||
lsp: Lsp,
|
||||
lsp_request_id: u64,
|
||||
lsp_source_cursor: Option<Cursor>,
|
||||
}
|
||||
|
||||
impl EditorSession {
|
||||
@@ -117,6 +121,9 @@ impl EditorSession {
|
||||
plugins,
|
||||
documents: BTreeMap::new(),
|
||||
requests: BTreeMap::new(),
|
||||
lsp: Lsp::new(config.lsp),
|
||||
lsp_request_id: 0,
|
||||
lsp_source_cursor: None,
|
||||
};
|
||||
session.synchronize_documents();
|
||||
Ok(session)
|
||||
@@ -145,6 +152,31 @@ impl EditorSession {
|
||||
}
|
||||
|
||||
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 result = match self.plugins.get_mut(&invocation.plugin) {
|
||||
Some(plugin) if plugin.is_async() => {
|
||||
@@ -178,7 +210,7 @@ impl EditorSession {
|
||||
}
|
||||
|
||||
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();
|
||||
return false;
|
||||
}
|
||||
@@ -250,6 +282,13 @@ impl EditorSession {
|
||||
});
|
||||
}
|
||||
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 {
|
||||
if !plugin.is_async() {
|
||||
continue;
|
||||
@@ -261,7 +300,7 @@ impl EditorSession {
|
||||
}
|
||||
}
|
||||
}
|
||||
let changed = !failures.is_empty();
|
||||
changed |= !failures.is_empty();
|
||||
for (owner, error) in failures {
|
||||
self.retire(&owner, &error);
|
||||
}
|
||||
@@ -282,6 +321,9 @@ impl EditorSession {
|
||||
) -> bool {
|
||||
self.editor.buffer_id() == source.buffer_id
|
||||
&& self.source_is_current(source)
|
||||
&& (owner != "core"
|
||||
|| (self.lsp_source_cursor == Some(self.editor.cursor())
|
||||
&& self.editor.mode() == Mode::Normal))
|
||||
&& self
|
||||
.requests
|
||||
.get(owner)
|
||||
@@ -406,6 +448,16 @@ impl EditorSession {
|
||||
/// Drains ready plugin work without waiting for command or server responses.
|
||||
pub fn poll(&mut self) -> bool {
|
||||
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();
|
||||
for (owner, plugin) in &mut self.plugins {
|
||||
if plugin.is_async() {
|
||||
|
||||
Executable → Regular
+102
-9
@@ -2,7 +2,7 @@
|
||||
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
path::PathBuf,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
@@ -17,7 +17,7 @@ use vamp::{
|
||||
|
||||
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.
|
||||
const SERVER: &str = r#"
|
||||
require 'json'
|
||||
@@ -213,13 +213,14 @@ impl Fixture {
|
||||
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 options = json!({
|
||||
"extensions": if mode == "irrelevant" { vec!["myl"] } else { vec!["rs"] },
|
||||
"command": command,
|
||||
"initialization_options": {"fixture": true},
|
||||
"settings": {"rust-analyzer": {"cargo": {"allFeatures": true}}},
|
||||
});
|
||||
let config = format!(
|
||||
"Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.plugin {}, options: JSON.parse({})\nend\n",
|
||||
serde_json::to_string(&plugin).unwrap(),
|
||||
"Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.lsp['fixture-language'] = JSON.parse({})\nend\n",
|
||||
serde_json::to_string(&options.to_string()).unwrap(),
|
||||
);
|
||||
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"],
|
||||
json!(["utf-16"])
|
||||
);
|
||||
assert_eq!(
|
||||
initialize["params"]["initializationOptions"],
|
||||
json!({"fixture": true})
|
||||
);
|
||||
assert_eq!(
|
||||
initialize["params"]["capabilities"]["textDocument"]["publishDiagnostics"]["versionSupport"],
|
||||
true
|
||||
@@ -340,6 +345,10 @@ fn lsp_framing_lifecycle_diagnostics_and_unicode_dirty_definition() {
|
||||
.position(|frame| frame["method"] == "initialized")
|
||||
.unwrap();
|
||||
assert!(initialized_index < open_index);
|
||||
assert_eq!(
|
||||
frames[open_index]["params"]["textDocument"]["languageId"],
|
||||
"fixture-language"
|
||||
);
|
||||
assert_eq!(
|
||||
frames[open_index]["params"]["textDocument"]["text"],
|
||||
"A😀e\u{301}Z\n"
|
||||
@@ -368,7 +377,7 @@ fn lsp_framing_lifecycle_diagnostics_and_unicode_dirty_definition() {
|
||||
.iter()
|
||||
.find(|frame| frame["id"] == "settings")
|
||||
.unwrap()["result"],
|
||||
json!([null, null])
|
||||
json!([{"cargo": {"allFeatures": true}}, null])
|
||||
);
|
||||
assert_eq!(
|
||||
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/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