feat: add asynchronous LSP plugin diagnostics and definitions
This commit is contained in:
@@ -13,6 +13,11 @@ Vamp.configure do |config|
|
||||
config.bind "ctrl+h", config.core.left, mode: config.modes.tree
|
||||
config.bind "ctrl+l", config.core.right, mode: config.modes.tree
|
||||
config.plugin "plugins/greeting.rb"
|
||||
# Optional LSP feature; install/configure a trusted language server first.
|
||||
# config.plugin "plugins/lsp.rb", options: {
|
||||
# servers: [{ language_id: "rust", extensions: ["rs"], command: ["rust-analyzer"] }]
|
||||
# }
|
||||
# config.bind "leader d", "lsp_definition"
|
||||
config.bind "g", "greet"
|
||||
config.bind "leader g", "greet"
|
||||
config.bind "ctrl+r", config.core.redo
|
||||
|
||||
@@ -0,0 +1,619 @@
|
||||
require "json"
|
||||
require "open3"
|
||||
require "uri"
|
||||
|
||||
# All server/protocol work stays in this optional plugin. Host callbacks only enqueue.
|
||||
module VampLsp
|
||||
MAX_BODY = 4 * 1024 * 1024
|
||||
MAX_HEADER = 16 * 1024
|
||||
QUEUE_SIZE = 128
|
||||
MAX_VERSION = (1 << 31) - 1
|
||||
|
||||
def self.clean(text)
|
||||
raise ArgumentError, "LSP message must be UTF-8 text" unless text.is_a?(String) && text.valid_encoding?
|
||||
text.gsub(/[[:cntrl:]]/, " ")
|
||||
end
|
||||
|
||||
def self.uri(path)
|
||||
"file://#{URI::DEFAULT_PARSER.escape(File.expand_path(path), /[^A-Za-z0-9\-._~\/]/)}"
|
||||
end
|
||||
|
||||
def self.path(uri)
|
||||
raise ArgumentError, "definition URI must be a String" unless uri.is_a?(String)
|
||||
parsed = URI.parse(uri)
|
||||
unless parsed.scheme == "file" && [nil, "", "localhost"].include?(parsed.host) &&
|
||||
parsed.userinfo.nil? && parsed.port.nil? && parsed.query.nil? && parsed.fragment.nil? &&
|
||||
parsed.path.start_with?("/") && !parsed.path.match?(/%(?![0-9a-fA-F]{2})/)
|
||||
raise ArgumentError, "unsupported local file URI: #{clean(uri)}"
|
||||
end
|
||||
path = URI::DEFAULT_PARSER.unescape(parsed.path).force_encoding(Encoding::UTF_8)
|
||||
raise ArgumentError, "invalid UTF-8 file URI" unless path.valid_encoding? && !path.match?(/[[:cntrl:]]/)
|
||||
File.expand_path(path)
|
||||
end
|
||||
|
||||
def self.identity(path)
|
||||
File.realpath(path)
|
||||
rescue SystemCallError
|
||||
File.expand_path(path)
|
||||
end
|
||||
|
||||
def self.lines(text)
|
||||
text.gsub(/\r\n?/, "\n").split("\n", -1)
|
||||
end
|
||||
|
||||
def self.integer(value, label)
|
||||
raise ArgumentError, "invalid LSP #{label}" unless value.is_a?(Integer) && value.between?(0, MAX_VERSION)
|
||||
value
|
||||
end
|
||||
|
||||
# Expand positions inside a surrogate pair or combining cluster to whole graphemes.
|
||||
def self.position(lines, value, round_up: false)
|
||||
raise ArgumentError, "invalid LSP position" unless value.is_a?(Hash)
|
||||
row = integer(value["line"], "line")
|
||||
units = integer(value["character"], "character")
|
||||
line = lines.fetch(row) { raise ArgumentError, "LSP line is outside the document" }
|
||||
offset = 0
|
||||
column = 0
|
||||
line.each_grapheme_cluster do |cluster|
|
||||
return { "row" => row, "column" => column } if units == offset
|
||||
width = cluster.encode(Encoding::UTF_16LE).bytesize / 2
|
||||
if units < offset + width
|
||||
return { "row" => row, "column" => column + (round_up ? 1 : 0) }
|
||||
end
|
||||
offset += width
|
||||
column += 1
|
||||
end
|
||||
# LSP specifies oversized character offsets as end-of-line.
|
||||
{ "row" => row, "column" => column }
|
||||
end
|
||||
|
||||
def self.range(lines, value)
|
||||
raise ArgumentError, "invalid LSP range" unless value.is_a?(Hash)
|
||||
first = value["start"]
|
||||
last = value["end"]
|
||||
start = position(lines, first)
|
||||
finish = position(lines, last, round_up: true)
|
||||
if ([integer(first["line"], "line"), integer(first["character"], "character")] <=>
|
||||
[integer(last["line"], "line"), integer(last["character"], "character")]) == 1
|
||||
raise ArgumentError, "reversed LSP range"
|
||||
end
|
||||
[start, finish]
|
||||
end
|
||||
|
||||
def self.outbound_position(text, row, column)
|
||||
line = lines(text).fetch(row) { raise ArgumentError, "definition source row is outside the document" }
|
||||
clusters = line.each_grapheme_cluster.to_a
|
||||
raise ArgumentError, "definition source column is outside the line" unless column.between?(0, clusters.length)
|
||||
{ "line" => row, "character" => clusters.take(column).join.encode(Encoding::UTF_16LE).bytesize / 2 }
|
||||
end
|
||||
|
||||
class Server
|
||||
attr_reader :root, :config, :documents, :pending, :requests, :ready
|
||||
attr_accessor :sync, :definition
|
||||
|
||||
def initialize(config, root, events)
|
||||
@config, @root, @events = config, root, events
|
||||
@documents, @pending, @requests = {}, [], {}
|
||||
@ready, @dead, @closing = false, false, false
|
||||
@next_id = 1
|
||||
@writes = SizedQueue.new(QUEUE_SIZE)
|
||||
@stdin, @stdout, @stderr, @wait = Open3.popen3(*config.fetch("command"), chdir: root)
|
||||
[@stdin, @stdout, @stderr].each(&:binmode)
|
||||
@reader = Thread.new do
|
||||
loop { @events.push([:incoming, self, read_frame], true) }
|
||||
rescue StandardError => error
|
||||
failed(error)
|
||||
end
|
||||
@writer = Thread.new do
|
||||
loop do
|
||||
bytes = @writes.pop
|
||||
break if bytes.nil?
|
||||
@stdin.write(bytes)
|
||||
@stdin.flush
|
||||
end
|
||||
rescue StandardError => error
|
||||
failed(error)
|
||||
end
|
||||
@drain = Thread.new do
|
||||
# Server stderr is intentionally not editor status or host protocol output.
|
||||
loop { @stderr.readpartial(8192) }
|
||||
rescue EOFError, IOError
|
||||
nil
|
||||
end
|
||||
request("initialize", {
|
||||
"processId" => Process.pid,
|
||||
"clientInfo" => { "name" => "vamp" },
|
||||
"rootUri" => VampLsp.uri(root),
|
||||
"workspaceFolders" => [{ "uri" => VampLsp.uri(root), "name" => File.basename(root) }],
|
||||
"capabilities" => {
|
||||
"general" => { "positionEncodings" => ["utf-16"] },
|
||||
"workspace" => { "configuration" => true, "workspaceFolders" => true },
|
||||
"textDocument" => {
|
||||
"synchronization" => { "dynamicRegistration" => false, "willSave" => false,
|
||||
"willSaveWaitUntil" => false, "didSave" => true },
|
||||
"definition" => { "dynamicRegistration" => false, "linkSupport" => true },
|
||||
"publishDiagnostics" => { "relatedInformation" => false, "versionSupport" => true }
|
||||
}
|
||||
}
|
||||
}, :initialize)
|
||||
rescue StandardError
|
||||
stop
|
||||
raise
|
||||
end
|
||||
|
||||
def dead?
|
||||
@dead
|
||||
end
|
||||
|
||||
def initialized!
|
||||
@ready = true
|
||||
end
|
||||
|
||||
def request(method, params, context)
|
||||
raise ArgumentError, "LSP request IDs exhausted" if @next_id > MAX_VERSION
|
||||
raise ThreadError, "LSP pending request queue is full" if @requests.length >= QUEUE_SIZE
|
||||
id = @next_id
|
||||
@next_id += 1
|
||||
@requests[id] = context
|
||||
frame = { "jsonrpc" => "2.0", "id" => id, "method" => method }
|
||||
frame["params"] = params unless params.nil?
|
||||
send_frame(frame)
|
||||
end
|
||||
|
||||
def notify(method, params)
|
||||
frame = { "jsonrpc" => "2.0", "method" => method }
|
||||
frame["params"] = params unless params.nil?
|
||||
send_frame(frame)
|
||||
end
|
||||
|
||||
def reply(id, result: nil, error: nil)
|
||||
frame = { "jsonrpc" => "2.0", "id" => id }
|
||||
frame[error ? "error" : "result"] = error || result
|
||||
send_frame(frame)
|
||||
end
|
||||
|
||||
def shutdown
|
||||
return if @dead || @closing
|
||||
@closing = true
|
||||
if @ready
|
||||
request("shutdown", nil, :shutdown)
|
||||
else
|
||||
stop
|
||||
end
|
||||
end
|
||||
|
||||
def stop
|
||||
return if @dead
|
||||
@dead = true
|
||||
[@stdin, @stdout, @stderr].compact.each { |io| io.close rescue nil }
|
||||
[@reader, @writer, @drain].compact.each { |thread| thread.kill unless thread == Thread.current }
|
||||
if @wait
|
||||
(Process.kill("TERM", @wait.pid) rescue nil) if @wait.alive?
|
||||
# Reaping and forced termination never block the host callback/dispatcher.
|
||||
Thread.new do
|
||||
unless @wait.join(0.2)
|
||||
Process.kill("KILL", @wait.pid) rescue nil
|
||||
@wait.join
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def send_frame(frame)
|
||||
raise IOError, "LSP server is unavailable" if @dead
|
||||
body = JSON.generate(frame).encode(Encoding::UTF_8)
|
||||
raise ArgumentError, "LSP outgoing frame exceeds 4 MiB" if body.bytesize > MAX_BODY
|
||||
@writes.push("Content-Length: #{body.bytesize}\r\n\r\n".b + body.b, true)
|
||||
rescue ThreadError
|
||||
raise ThreadError, "LSP outgoing queue is full"
|
||||
end
|
||||
|
||||
def read_frame
|
||||
size = 0
|
||||
length = nil
|
||||
loop do
|
||||
line = @stdout.gets("\r\n", MAX_HEADER + 1)
|
||||
raise EOFError, "LSP server exited" unless line
|
||||
size += line.bytesize
|
||||
raise ArgumentError, "LSP headers exceed 16 KiB" if size > MAX_HEADER
|
||||
raise ArgumentError, "LSP headers must be ASCII" unless line.ascii_only?
|
||||
raise ArgumentError, "invalid LSP header newline" unless line.end_with?("\r\n")
|
||||
break if line == "\r\n"
|
||||
name, value = line.chomp("\r\n").split(":", 2)
|
||||
raise ArgumentError, "invalid LSP header" unless value
|
||||
if name.downcase == "content-length"
|
||||
raise ArgumentError, "invalid LSP Content-Length" if length || !value.strip.match?(/\A[0-9]+\z/)
|
||||
length = value.to_i
|
||||
elsif name.downcase == "content-type" && value.match?(/charset=/i) &&
|
||||
!value.match?(/charset\s*=\s*utf-?8\s*\z/i)
|
||||
raise ArgumentError, "unsupported LSP content encoding"
|
||||
end
|
||||
end
|
||||
raise ArgumentError, "invalid LSP body length" unless length && length.between?(1, MAX_BODY)
|
||||
body = @stdout.read(length)
|
||||
raise EOFError, "truncated LSP body" unless body && body.bytesize == length
|
||||
body.force_encoding(Encoding::UTF_8)
|
||||
raise ArgumentError, "invalid UTF-8 LSP body" unless body.valid_encoding?
|
||||
JSON.parse(body)
|
||||
end
|
||||
|
||||
def failed(error)
|
||||
return if @dead || @closing
|
||||
@events.push([:failure, self, error.message], true)
|
||||
rescue ThreadError
|
||||
begin
|
||||
Vamp.publish([{ "type" => "message", "text" => "LSP incoming queue is full" }])
|
||||
ensure
|
||||
Kernel.exit!(1) # Host EOF retires the feature and clears all owned diagnostics.
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.clear(doc)
|
||||
{ "type" => "diagnostics", "buffer_id" => doc.fetch("id"),
|
||||
"revision" => doc.fetch("revision"), "items" => [] }
|
||||
end
|
||||
|
||||
class Client
|
||||
def initialize(options)
|
||||
servers = options["servers"]
|
||||
unless servers.is_a?(Array) && !servers.empty? && servers.length <= 16
|
||||
raise ArgumentError, "LSP options.servers must contain 1..16 server configurations"
|
||||
end
|
||||
@configs = servers.map do |config|
|
||||
unless config.is_a?(Hash) && config["language_id"].is_a?(String) && !config["language_id"].empty? &&
|
||||
config["extensions"].is_a?(Array) && !config["extensions"].empty? &&
|
||||
config["extensions"].all? { |ext| ext.is_a?(String) && ext.match?(/\A[^.\/\\\s\x00]+\z/) } &&
|
||||
config["command"].is_a?(Array) && !config["command"].empty? &&
|
||||
config["command"].all? { |arg| arg.is_a?(String) && !arg.include?("\0") } &&
|
||||
!config["command"].first.empty?
|
||||
raise ArgumentError, "LSP server requires language_id, extensions, and command argv"
|
||||
end
|
||||
config
|
||||
end
|
||||
@events = SizedQueue.new(QUEUE_SIZE)
|
||||
@documents, @servers = {}, {}
|
||||
@failure = nil
|
||||
@worker = Thread.new { run }
|
||||
end
|
||||
|
||||
def enqueue(*event)
|
||||
raise IOError, @failure if @failure
|
||||
@events.push(event, true)
|
||||
nil
|
||||
rescue ThreadError
|
||||
raise IOError, "LSP document/command queue is full"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def run
|
||||
loop do
|
||||
event, *args = @events.pop
|
||||
case event
|
||||
when :document then document(*args)
|
||||
when :definition then definition(*args)
|
||||
when :diagnostics then diagnostics_status(*args)
|
||||
when :incoming then incoming(*args)
|
||||
when :failure then fail_server(*args)
|
||||
when :shutdown
|
||||
@servers.values.compact.each(&:shutdown)
|
||||
# The host process-group cleanup is the final safety net, including descendants.
|
||||
end
|
||||
rescue StandardError => error
|
||||
server = [:incoming, :failure].include?(event) ? args.first : nil
|
||||
if server.is_a?(Server) && !error.is_a?(ThreadError)
|
||||
fail_server(server, error.message)
|
||||
else
|
||||
@failure = "LSP client: #{VampLsp.clean(error.message)}"
|
||||
@servers.values.compact.each(&:stop)
|
||||
begin
|
||||
Vamp.publish([{ "type" => "message", "text" => @failure }])
|
||||
ensure
|
||||
Kernel.exit!(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def message(text, request_id: nil)
|
||||
Vamp.publish([{ "type" => "message", "text" => "LSP: #{VampLsp.clean(text)}" }], request_id: request_id)
|
||||
end
|
||||
|
||||
def server_for(doc)
|
||||
return unless doc["path"]
|
||||
extension = File.extname(doc["path"]).delete_prefix(".")
|
||||
index = @configs.index { |config| config["extensions"].include?(extension) }
|
||||
return unless index
|
||||
key = [index, File.expand_path(doc.fetch("root"))]
|
||||
return @servers[key] if @servers.key?(key)
|
||||
begin
|
||||
@servers[key] = Server.new(@configs[index], key.last, @events)
|
||||
rescue StandardError => error
|
||||
# Remember failed starts: no automatic retries or restart loop.
|
||||
@servers[key] = nil
|
||||
message("#{@configs[index]["language_id"]} server failed to start: #{error.message}")
|
||||
end
|
||||
end
|
||||
|
||||
def document(event)
|
||||
kind = event.fetch("event")
|
||||
snapshot = event.fetch("document")
|
||||
id = snapshot.fetch("id")
|
||||
old = @documents[id]
|
||||
if kind == "close"
|
||||
if old && (server = old["server"])
|
||||
close_document(server, old)
|
||||
end
|
||||
@documents.delete(id)
|
||||
return
|
||||
end
|
||||
doc = snapshot.merge("uri" => snapshot["path"] && VampLsp.uri(snapshot["path"]),
|
||||
"server" => old ? old["server"] : server_for(snapshot),
|
||||
"version" => old ? old["version"] : 1,
|
||||
"opened" => old ? old["opened"] : false,
|
||||
"saved" => kind == "save")
|
||||
server = doc["server"]
|
||||
@documents[id] = doc
|
||||
return unless server && !server.dead?
|
||||
if old && old["revision"] != doc["revision"]
|
||||
raise ArgumentError, "LSP document version exhausted" if doc["version"] == MAX_VERSION
|
||||
doc["version"] += 1
|
||||
Vamp.publish([VampLsp.clear(doc)])
|
||||
end
|
||||
server.documents[doc["uri"]] = doc
|
||||
return unless server.ready
|
||||
if !doc["opened"]
|
||||
open_document(server, doc)
|
||||
elsif old && old["revision"] != doc["revision"]
|
||||
change_document(server, old, doc)
|
||||
end
|
||||
save_document(server, doc) if kind == "save"
|
||||
end
|
||||
|
||||
def open_document(server, doc)
|
||||
doc["opened"] = true
|
||||
return unless server.sync["openClose"]
|
||||
server.notify("textDocument/didOpen", {
|
||||
"textDocument" => { "uri" => doc["uri"], "languageId" => server.config["language_id"],
|
||||
"version" => doc["version"], "text" => doc["text"] }
|
||||
})
|
||||
end
|
||||
|
||||
def change_document(server, old, doc)
|
||||
kind = server.sync["change"]
|
||||
return if kind == 0
|
||||
change = { "text" => doc["text"] }
|
||||
if kind == 2
|
||||
old_lines = VampLsp.lines(old["text"])
|
||||
change["range"] = { "start" => { "line" => 0, "character" => 0 },
|
||||
"end" => { "line" => old_lines.length - 1,
|
||||
"character" => old_lines.last.encode(Encoding::UTF_16LE).bytesize / 2 } }
|
||||
end
|
||||
server.notify("textDocument/didChange", {
|
||||
"textDocument" => { "uri" => doc["uri"], "version" => doc["version"] },
|
||||
"contentChanges" => [change]
|
||||
})
|
||||
end
|
||||
|
||||
def save_document(server, doc)
|
||||
save = server.sync["save"]
|
||||
return unless save
|
||||
params = { "textDocument" => { "uri" => doc["uri"] } }
|
||||
params["text"] = doc["text"] if save.is_a?(Hash) && save["includeText"]
|
||||
server.notify("textDocument/didSave", params)
|
||||
doc["saved"] = false
|
||||
end
|
||||
|
||||
def close_document(server, doc)
|
||||
server.documents.delete(doc["uri"])
|
||||
return unless server.ready && !server.dead? && doc["opened"] && server.sync["openClose"]
|
||||
server.notify("textDocument/didClose", { "textDocument" => { "uri" => doc["uri"] } })
|
||||
end
|
||||
|
||||
def definition(source)
|
||||
doc = @documents[source.fetch("id")]
|
||||
return unless doc && doc["revision"] == source["revision"]
|
||||
server = doc["server"]
|
||||
unless server && !server.dead?
|
||||
message("no running server for this document", request_id: source["request_id"])
|
||||
return
|
||||
end
|
||||
unless server.ready
|
||||
raise ThreadError, "LSP pending command queue is full" if server.pending.length == QUEUE_SIZE
|
||||
server.pending << source
|
||||
return
|
||||
end
|
||||
unless server.definition
|
||||
message("server does not support go-to-definition", request_id: source["request_id"])
|
||||
return
|
||||
end
|
||||
server.request("textDocument/definition", {
|
||||
"textDocument" => { "uri" => doc["uri"] },
|
||||
"position" => VampLsp.outbound_position(source["text"], source["row"], source["column"])
|
||||
}, source)
|
||||
end
|
||||
|
||||
def diagnostics_status(id)
|
||||
doc = @documents[id]
|
||||
server = doc && doc["server"]
|
||||
message(if !server || server.dead?
|
||||
"no running server for this document"
|
||||
elsif !server.ready
|
||||
"server initializing"
|
||||
else
|
||||
"diagnostics update automatically from #{server.config["language_id"]}"
|
||||
end)
|
||||
end
|
||||
|
||||
def incoming(server, frame)
|
||||
return if server.dead?
|
||||
unless frame.is_a?(Hash) && frame["jsonrpc"] == "2.0"
|
||||
raise ArgumentError, "invalid LSP JSON-RPC frame"
|
||||
end
|
||||
if frame.key?("method")
|
||||
method = frame["method"]
|
||||
raise ArgumentError, "invalid LSP method" unless method.is_a?(String)
|
||||
if frame.key?("id")
|
||||
server_request(server, frame)
|
||||
elsif method == "textDocument/publishDiagnostics"
|
||||
publish_diagnostics(server, frame["params"])
|
||||
elsif method == "window/showMessage"
|
||||
params = frame["params"]
|
||||
raise ArgumentError, "invalid LSP showMessage" unless params.is_a?(Hash)
|
||||
message(params["message"])
|
||||
end
|
||||
return
|
||||
end
|
||||
id = frame["id"]
|
||||
unless id.is_a?(Integer) && server.requests.key?(id) &&
|
||||
(frame.key?("result") ^ frame.key?("error"))
|
||||
raise ArgumentError, "unexpected LSP response"
|
||||
end
|
||||
context = server.requests.delete(id)
|
||||
if context == :shutdown
|
||||
server.notify("exit", nil)
|
||||
return
|
||||
end
|
||||
if frame.key?("error")
|
||||
error = frame["error"]
|
||||
unless error.is_a?(Hash) && error["code"].is_a?(Integer) && error["message"].is_a?(String)
|
||||
raise ArgumentError, "invalid LSP error response"
|
||||
end
|
||||
raise IOError, "initialization failed: #{error["message"]}" if context == :initialize
|
||||
message("definition failed: #{error["message"]}", request_id: context["request_id"])
|
||||
elsif context == :initialize
|
||||
initialize_result(server, frame["result"])
|
||||
else
|
||||
definition_result(context, frame["result"])
|
||||
end
|
||||
end
|
||||
|
||||
def initialize_result(server, result)
|
||||
capabilities = result.is_a?(Hash) && result["capabilities"]
|
||||
raise ArgumentError, "invalid LSP initialize result" unless capabilities.is_a?(Hash)
|
||||
encoding = capabilities.fetch("positionEncoding", "utf-16")
|
||||
raise ArgumentError, "server selected unsupported position encoding #{encoding}" unless encoding == "utf-16"
|
||||
sync = capabilities.fetch("textDocumentSync", 0)
|
||||
if sync.is_a?(Integer) && sync.between?(0, 2)
|
||||
sync = { "openClose" => sync != 0, "change" => sync, "save" => false }
|
||||
elsif sync.is_a?(Hash)
|
||||
sync = { "openClose" => sync.fetch("openClose", false), "change" => sync.fetch("change", 0),
|
||||
"save" => sync.fetch("save", false) }
|
||||
else
|
||||
raise ArgumentError, "invalid LSP textDocumentSync capability"
|
||||
end
|
||||
unless [true, false].include?(sync["openClose"]) && [0, 1, 2].include?(sync["change"]) &&
|
||||
([true, false].include?(sync["save"]) ||
|
||||
(sync["save"].is_a?(Hash) && [true, false].include?(sync["save"].fetch("includeText", false))))
|
||||
raise ArgumentError, "invalid LSP synchronization options"
|
||||
end
|
||||
server.sync = sync
|
||||
server.definition = capabilities["definitionProvider"] == true || capabilities["definitionProvider"].is_a?(Hash)
|
||||
server.notify("initialized", {})
|
||||
server.initialized!
|
||||
server.documents.each_value do |doc|
|
||||
open_document(server, doc)
|
||||
save_document(server, doc) if doc["saved"]
|
||||
end
|
||||
pending = server.pending.dup
|
||||
server.pending.clear
|
||||
pending.each { |source| definition(source) }
|
||||
end
|
||||
|
||||
def server_request(server, frame)
|
||||
id = frame["id"]
|
||||
raise ArgumentError, "invalid LSP server request ID" unless id.is_a?(Integer) || id.is_a?(String)
|
||||
case frame["method"]
|
||||
when "workspace/configuration"
|
||||
params = frame["params"]
|
||||
items = params.is_a?(Hash) && params["items"]
|
||||
raise ArgumentError, "invalid LSP configuration request" unless items.is_a?(Array)
|
||||
server.reply(id, result: items.map { nil })
|
||||
when "workspace/workspaceFolders"
|
||||
server.reply(id, result: [{ "uri" => VampLsp.uri(server.root), "name" => File.basename(server.root) }])
|
||||
when "window/showMessageRequest"
|
||||
server.reply(id, result: nil)
|
||||
else
|
||||
server.reply(id, error: { "code" => -32601, "message" => "Method not supported by vamp" })
|
||||
end
|
||||
end
|
||||
|
||||
def publish_diagnostics(server, params)
|
||||
unless params.is_a?(Hash) && params["uri"].is_a?(String) && params["diagnostics"].is_a?(Array)
|
||||
raise ArgumentError, "invalid LSP publishDiagnostics"
|
||||
end
|
||||
doc = server.documents[params["uri"]]
|
||||
return unless doc
|
||||
if params.key?("version") && !params["version"].nil?
|
||||
version = params["version"]
|
||||
unless version.is_a?(Integer) && version.between?(-(1 << 31), MAX_VERSION)
|
||||
raise ArgumentError, "invalid LSP diagnostic version"
|
||||
end
|
||||
return if version < doc["version"]
|
||||
raise ArgumentError, "LSP diagnostics refer to an unknown future version" if version > doc["version"]
|
||||
end
|
||||
# LSP permits no version: only arrival-time revision is knowable in that case.
|
||||
text_lines = VampLsp.lines(doc["text"])
|
||||
items = params["diagnostics"].map do |item|
|
||||
raise ArgumentError, "invalid LSP diagnostic" unless item.is_a?(Hash)
|
||||
start, finish = VampLsp.range(text_lines, item["range"])
|
||||
severity = { 1 => "error", 2 => "warning", 3 => "information", 4 => "hint" }[item.fetch("severity", 1)]
|
||||
raise ArgumentError, "invalid LSP diagnostic severity" unless severity
|
||||
{ "start" => start, "end" => finish, "severity" => severity,
|
||||
"message" => VampLsp.clean(item["message"]) }
|
||||
end
|
||||
Vamp.publish([{ "type" => "diagnostics", "buffer_id" => doc["id"],
|
||||
"revision" => doc["revision"], "items" => items }])
|
||||
end
|
||||
|
||||
def definition_result(source, result)
|
||||
doc = @documents[source["id"]]
|
||||
return unless doc && doc["revision"] == source["revision"]
|
||||
locations = result.nil? ? [] : (result.is_a?(Array) ? result : [result])
|
||||
if locations.empty?
|
||||
message("no definition found", request_id: source["request_id"])
|
||||
return
|
||||
end
|
||||
location = locations.first
|
||||
raise ArgumentError, "invalid LSP definition location" unless location.is_a?(Hash)
|
||||
link = location.key?("targetUri")
|
||||
path = VampLsp.path(location[link ? "targetUri" : "uri"])
|
||||
identity = VampLsp.identity(path)
|
||||
cached = @documents.values.find { |candidate| candidate["path"] && VampLsp.identity(candidate["path"]) == identity }
|
||||
text = cached ? cached["text"] : File.read(path, encoding: Encoding::UTF_8)
|
||||
raise ArgumentError, "definition target is not UTF-8" unless text.valid_encoding?
|
||||
text_lines = VampLsp.lines(text)
|
||||
VampLsp.range(text_lines, location["targetRange"]) if link
|
||||
start, = VampLsp.range(text_lines, location[link ? "targetSelectionRange" : "range"])
|
||||
effects = [{ "type" => "navigate", "buffer_id" => source["id"], "revision" => source["revision"],
|
||||
"path" => path, "row" => start["row"], "column" => start["column"] }]
|
||||
if locations.length > 1
|
||||
effects << { "type" => "message", "text" => "LSP: selected first of #{locations.length} definitions" }
|
||||
end
|
||||
Vamp.publish(effects, request_id: source["request_id"])
|
||||
rescue SystemCallError, ArgumentError => error
|
||||
message("definition failed: #{error.message}", request_id: source["request_id"])
|
||||
end
|
||||
|
||||
def fail_server(server, reason)
|
||||
return if server.dead?
|
||||
server.stop
|
||||
Vamp.publish(server.documents.values.map { |doc| VampLsp.clear(doc) } +
|
||||
[{ "type" => "message", "text" => "LSP server stopped: #{VampLsp.clean(reason)}" }])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Vamp.plugin "lsp", requires: ["text", "diagnostics"], async: true
|
||||
client = VampLsp::Client.new(Vamp.options)
|
||||
Vamp.on_document { |event| client.enqueue(:document, event) }
|
||||
Vamp.command "lsp_definition" do |editor, _args|
|
||||
client.enqueue(:definition, { "id" => editor.buffer_id, "revision" => editor.revision,
|
||||
"text" => editor.text, "row" => editor.row - 1,
|
||||
"column" => editor.column - 1, "request_id" => editor.request_id })
|
||||
end
|
||||
Vamp.command("lsp_diagnostics") { |editor, _args| client.enqueue(:diagnostics, editor.buffer_id) }
|
||||
Vamp.on_shutdown { client.enqueue(:shutdown) }
|
||||
Reference in New Issue
Block a user