feat: migrate LSP to opt-in native language servers

This commit is contained in:
Valdemar Lorenzen
2026-10-09 13:12:38 +02:00
parent 92679ecbe1
commit 30aa70672e
12 changed files with 2407 additions and 658 deletions
+28 -16
View File
@@ -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"] }
]
}
config.bind "leader d", "lsp_definition"
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
View File
@@ -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"
-619
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+154
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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() {
+102 -9
View File
@@ -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);
}