Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Experimental Language Server Protocol support #79

Merged
merged 2 commits into from
Mar 5, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions lib/steep.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
require "rainbow"
require "listen"
require 'pry'
require 'language_server-protocol'

require "steep/ast/namespace"
require "steep/names"
Expand Down Expand Up @@ -85,6 +86,7 @@
require "steep/drivers/scaffold"
require "steep/drivers/print_interface"
require "steep/drivers/watch"
require "steep/drivers/langserver"

if ENV["NO_COLOR"]
Rainbow.enabled = false
Expand Down
27 changes: 26 additions & 1 deletion lib/steep/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ def initialize(stdout:, stdin:, stderr:, argv:)
end

def self.available_commands
[:check, :validate, :annotations, :scaffold, :interface, :version, :paths, :watch]
[:check, :validate, :annotations, :scaffold, :interface, :version, :paths, :watch, :langserver]
end

def process_global_options
Expand Down Expand Up @@ -270,6 +270,31 @@ def process_watch
end
end

def process_langserver
with_signature_options do |signature_options|
strict = false
fallback_any_is_error = false

OptionParser.new do |opts|
handle_dir_options opts, signature_options
opts.on("--strict") { strict = true }
opts.on("--fallback-any-is-error") { fallback_any_is_error = true }
end.parse!(argv)

source_dirs = argv.map { |path| Pathname(path) }
if source_dirs.empty?
source_dirs << Pathname(".")
end

Drivers::Langserver.new(source_dirs: source_dirs, signature_dirs: signature_options.paths).tap do |driver|
driver.options.fallback_any_is_error = fallback_any_is_error || strict
driver.options.allow_missing_definitions = false if strict
end.run

0
end
end

def process_version
stdout.puts Steep::VERSION
0
Expand Down
134 changes: 134 additions & 0 deletions lib/steep/drivers/langserver.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
module Steep
module Drivers
class Langserver
attr_reader :source_dirs
attr_reader :signature_dirs
attr_reader :options
attr_reader :subscribers

include Utils::EachSignature

def initialize(source_dirs:, signature_dirs:)
@source_dirs = source_dirs
@signature_dirs = signature_dirs
@options = Project::Options.new
@subscribers = {}

subscribe :initialize do |request:, notifier:|
LanguageServer::Protocol::Interface::InitializeResult.new(
capabilities: LanguageServer::Protocol::Interface::ServerCapabilities.new(
text_document_sync: LanguageServer::Protocol::Interface::TextDocumentSyncOptions.new(
open_close: true,
change: LanguageServer::Protocol::Constant::TextDocumentSyncKind::FULL,
),
),
)
end

subscribe :shutdown do |request:, notifier:|
Steep.logger.warn "Shutting down the server..."
exit
end

subscribe :"textDocument/didOpen" do |request:, notifier:|
uri = URI.parse(request[:params][:textDocument][:uri])
text = request[:params][:textDocument][:text]
synchronize_project(uri: uri, text: text, notifier: notifier)
end

subscribe :"textDocument/didChange" do |request:, notifier:|
uri = URI.parse(request[:params][:textDocument][:uri])
text = request[:params][:contentChanges][0][:text]
synchronize_project(uri: uri, text: text, notifier: notifier)
end
end

def subscribe(method, &callback)
@subscribers[method] = callback
end

def project
@project ||= Project.new.tap do |project|
source_dirs.each do |path|
each_file_in_path(".rb", path) do |file_path|
file = Project::SourceFile.new(path: file_path, options: options)
file.content = file_path.read
project.source_files[file_path] = file
end
end

signature_dirs.each do |path|
each_file_in_path(".rbi", path) do |file_path|
file = Project::SignatureFile.new(path: file_path)
file.content = file_path.read
project.signature_files[file_path] = file
end
end
end
end

def run
writer = LanguageServer::Protocol::Transport::Stdio::Writer.new
reader = LanguageServer::Protocol::Transport::Stdio::Reader.new
notifier = Proc.new { |method:, params: {}| writer.write(method: method, params: params) }

reader.read do |request|
id = request[:id]
method = request[:method].to_sym
Steep.logger.warn "Received event: #{method}"
subscriber = subscribers[method]
if subscriber
result = subscriber.call(request: request, notifier: notifier)
if id
writer.write(id: id, result: result)
end
else
Steep.logger.warn "Ignored event: #{method}"
end
end
end

def synchronize_project(uri:, text:, notifier:)
path = Pathname(uri.path).relative_path_from(Pathname.pwd)

case path.extname
when ".rb"
file = project.source_files[path] || Project::SourceFile.new(path: path, options: options)
file.content = text
project.source_files[path] = file
project.type_check

diags = (file.errors || []).map do |error|
LanguageServer::Protocol::Interface::Diagnostic.new(
message: error.to_s,
severity: LanguageServer::Protocol::Constant::DiagnosticSeverity::ERROR,
range: LanguageServer::Protocol::Interface::Range.new(
start: LanguageServer::Protocol::Interface::Position.new(
line: error.node.loc.line - 1,
character: error.node.loc.column,
),
end: LanguageServer::Protocol::Interface::Position.new(
line: error.node.loc.last_line - 1,
character: error.node.loc.last_column,
),
)
)
end

notifier.call(
method: :"textDocument/publishDiagnostics",
params: LanguageServer::Protocol::Interface::PublishDiagnosticsParams.new(
uri: uri,
diagnostics: diags,
),
)
when ".rbi"
file = project.signature_files[path] || Project::SignatureFile.new(path: path)
file.content = text
project.signature_files[path] = file
project.type_check
end
end
end
end
end
3 changes: 3 additions & 0 deletions lib/steep/project/file.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ def parse
rescue ::Parser::SyntaxError => exn
Steep.logger.warn { "Syntax error on #{path}: #{exn.inspect}" }
exn
rescue EncodingError => exn
Steep.logger.warn { "Encoding error on #{path}: #{exn.inspect}" }
exn
end
end

Expand Down
1 change: 1 addition & 0 deletions steep.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,5 @@ Gem::Specification.new do |spec|
spec.add_runtime_dependency "rainbow", "~> 2.2.2", "< 4.0"
spec.add_runtime_dependency "listen", "~> 3.1"
spec.add_runtime_dependency "pry", "~> 0.12.2"
spec.add_runtime_dependency "language_server-protocol", "~> 3.14.0"
end
116 changes: 116 additions & 0 deletions test/langserver_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
require_relative "test_helper"

class LangserverTest < Minitest::Test
include ShellHelper

def dirs
@dirs ||= []
end

def langserver_command
"#{__dir__}/../exe/steep langserver #{current_dir}"
end

def jsonrpc(hash)
hash_str = hash.to_json
"Content-Length: #{hash_str.bytesize}\r\n" + "\r\n" + hash_str
end

def test_initialize
in_tmpdir do
Open3.popen3(langserver_command) do |stdin, stdout, stderr, wait_thr|
stdin.puts jsonrpc(
id: 0,
method: "initialize",
params: {},
jsonrpc: "2.0",
)
stdin.close
wait_thr.join

assert_equal jsonrpc(
id: 0,
result: {
capabilities: {
textDocumentSync: { openClose: true, change: 1 }
}
},
jsonrpc: "2.0",
), stdout.read
end
end
end

def test_did_open
in_tmpdir do
Open3.popen3(langserver_command) do |stdin, stdout, stderr, wait_thr|
stdin.puts jsonrpc(
method: "textDocument/didOpen",
params: {
textDocument: {
uri: "file:///root/workdir/example.rb",
languageId: "ruby",
version: 1,
text: "[\"foo\"].join(\",\").map {|str| puts str}",
}
}
)
stdin.close
wait_thr.join

assert_equal jsonrpc(
method: "textDocument/publishDiagnostics",
params: {
uri: "file:///root/workdir/example.rb",
diagnostics: [{
range: {
start: { line: 0, character: 0 },
end: { line: 0, character: 38 },
},
severity: 1,
message: "#{Pathname("/root/workdir/example.rb").relative_path_from(Pathname.pwd)}:1:0: NoMethodError: type=::String, method=map"
}]
},
jsonrpc: "2.0",
), stdout.read
end
end
end

def test_did_change
in_tmpdir do
Open3.popen3(langserver_command) do |stdin, stdout, stderr, wait_thr|
stdin.puts jsonrpc(
method: "textDocument/didChange",
params: {
textDocument: {
uri: "file:///root/workdir/example.rb",
version: 2,
},
contentChanges: [
{ text: "[\"foo\"].join(\",\").map {|str| puts str}" }
]
}
)
stdin.close
wait_thr.join

assert_equal jsonrpc(
method: "textDocument/publishDiagnostics",
params: {
uri: "file:///root/workdir/example.rb",
diagnostics: [{
range: {
start: { line: 0, character: 0 },
end: { line: 0, character: 38 },
},
severity: 1,
message: "#{Pathname("/root/workdir/example.rb").relative_path_from(Pathname.pwd)}:1:0: NoMethodError: type=::String, method=map"
}]
},
jsonrpc: "2.0",
), stdout.read
end
end
end
end