summaryrefslogtreecommitdiff
path: root/lib/prism/language_server.rb
diff options
context:
space:
mode:
Diffstat (limited to 'lib/prism/language_server.rb')
-rw-r--r--lib/prism/language_server.rb166
1 files changed, 166 insertions, 0 deletions
diff --git a/lib/prism/language_server.rb b/lib/prism/language_server.rb
new file mode 100644
index 0000000000..5a10d484a1
--- /dev/null
+++ b/lib/prism/language_server.rb
@@ -0,0 +1,166 @@
+# frozen_string_literal: true
+
+require "cgi"
+require "json"
+require "uri"
+
+module YARP
+ # YARP additionally ships with a language server conforming to the
+ # language server protocol. It can be invoked by running the yarp-lsp
+ # bin script (bin/yarp-lsp)
+ class LanguageServer
+ GITHUB_TEMPLATE = <<~TEMPLATE
+ Reporting issue with error `%{error}`.
+
+ ## Expected behavior
+ <!-- TODO: Briefly explain what the expected behavior should be on this example. -->
+
+ ## Actual behavior
+ <!-- TODO: Describe here what actually happened. -->
+
+ ## Steps to reproduce the problem
+ <!-- TODO: Describe how we can reproduce the problem. -->
+
+ ## Additional information
+ <!-- TODO: Include any additional information, such as screenshots. -->
+
+ TEMPLATE
+
+ attr_reader :input, :output
+
+ def initialize(
+ input: $stdin,
+ output: $stdout
+ )
+ @input = input.binmode
+ @output = output.binmode
+ end
+
+ # rubocop:disable Layout/LineLength
+ def run
+ store =
+ Hash.new do |hash, uri|
+ filepath = CGI.unescape(URI.parse(uri).path)
+ File.exist?(filepath) ? (hash[uri] = File.read(filepath)) : nil
+ end
+
+ while (headers = input.gets("\r\n\r\n"))
+ source = input.read(headers[/Content-Length: (\d+)/i, 1].to_i)
+ request = JSON.parse(source, symbolize_names: true)
+
+ # stree-ignore
+ case request
+ in { method: "initialize", id: }
+ store.clear
+ write(id: id, result: { capabilities: capabilities })
+ in { method: "initialized" }
+ # ignored
+ in { method: "shutdown" } # tolerate missing ID to be a good citizen
+ store.clear
+ write(id: request[:id], result: {})
+ in { method: "exit"}
+ return
+ in { method: "textDocument/didChange", params: { textDocument: { uri: }, contentChanges: [{ text: }, *] } }
+ store[uri] = text
+ in { method: "textDocument/didOpen", params: { textDocument: { uri:, text: } } }
+ store[uri] = text
+ in { method: "textDocument/didClose", params: { textDocument: { uri: } } }
+ store.delete(uri)
+ in { method: "textDocument/diagnostic", id:, params: { textDocument: { uri: } } }
+ contents = store[uri]
+ write(id: id, result: contents ? diagnostics(contents) : nil)
+ in { method: "textDocument/codeAction", id:, params: { textDocument: { uri: }, context: { diagnostics: }}}
+ contents = store[uri]
+ write(id: id, result: contents ? code_actions(contents, diagnostics) : nil)
+ in { method: %r{\$/.+} }
+ # ignored
+ end
+ end
+ end
+ # rubocop:enable Layout/LineLength
+
+ private
+
+ def capabilities
+ {
+ codeActionProvider: {
+ codeActionKinds: [
+ 'quickfix',
+ ],
+ },
+ diagnosticProvider: {
+ interFileDependencies: false,
+ workspaceDiagnostics: false,
+ },
+ textDocumentSync: {
+ change: 1,
+ openClose: true
+ },
+ }
+ end
+
+ def code_actions(source, diagnostics)
+ diagnostics.map do |diagnostic|
+ message = diagnostic[:message]
+ issue_content = URI.encode_www_form_component(GITHUB_TEMPLATE % {error: message})
+ issue_link = "https://github.com/ruby/yarp/issues/new?&labels=Bug&body=#{issue_content}"
+
+ {
+ title: "Report incorrect error: `#{diagnostic[:message]}`",
+ kind: "quickfix",
+ diagnostics: [diagnostic],
+ command: {
+ title: "Report incorrect error",
+ command: "vscode.open",
+ arguments: [issue_link]
+ }
+ }
+ end
+ end
+
+ def diagnostics(source)
+ offsets = Hash.new do |hash, key|
+ slice = source.byteslice(...key)
+ lineno = slice.count("\n")
+
+ char = slice.length
+ newline = source.rindex("\n", [char - 1, 0].max) || -1
+ hash[key] = { line: lineno, character: char - newline - 1 }
+ end
+
+ parse_output = YARP.parse(source)
+
+ {
+ kind: "full",
+ items: [
+ *parse_output.errors.map do |error|
+ {
+ range: {
+ start: offsets[error.location.start_offset],
+ end: offsets[error.location.end_offset],
+ },
+ message: error.message,
+ severity: 1,
+ }
+ end,
+ *parse_output.warnings.map do |warning|
+ {
+ range: {
+ start: offsets[warning.location.start_offset],
+ end: offsets[warning.location.end_offset],
+ },
+ message: warning.message,
+ severity: 2,
+ }
+ end,
+ ]
+ }
+ end
+
+ def write(value)
+ response = value.merge(jsonrpc: "2.0").to_json
+ output.print("Content-Length: #{response.bytesize}\r\n\r\n#{response}")
+ output.flush
+ end
+ end
+end