spoom 1.0.3 → 1.0.4
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/Gemfile +4 -0
- data/Rakefile +1 -0
- data/exe/spoom +7 -0
- data/lib/spoom.rb +8 -1
- data/lib/spoom/cli.rb +33 -0
- data/lib/spoom/cli/commands/base.rb +36 -0
- data/lib/spoom/cli/commands/config.rb +67 -0
- data/lib/spoom/cli/commands/lsp.rb +156 -0
- data/lib/spoom/cli/commands/run.rb +92 -0
- data/lib/spoom/cli/symbol_printer.rb +71 -0
- data/lib/spoom/config.rb +11 -0
- data/lib/spoom/sorbet.rb +70 -0
- data/lib/spoom/sorbet/config.rb +21 -9
- data/lib/spoom/sorbet/errors.rb +129 -0
- data/lib/spoom/sorbet/lsp.rb +194 -0
- data/lib/spoom/sorbet/lsp/base.rb +58 -0
- data/lib/spoom/sorbet/lsp/errors.rb +45 -0
- data/lib/spoom/sorbet/lsp/structures.rb +218 -0
- data/lib/spoom/sorbet/metrics.rb +102 -0
- data/lib/spoom/version.rb +2 -1
- metadata +58 -16
data/lib/spoom/config.rb
ADDED
@@ -0,0 +1,11 @@
|
|
1
|
+
# typed: true
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module Spoom
|
5
|
+
module Config
|
6
|
+
SORBET_CONFIG = "sorbet/config"
|
7
|
+
SORBET_GEM_PATH = Gem::Specification.find_by_name("sorbet-static").full_gem_path
|
8
|
+
SORBET_PATH = (Pathname.new(SORBET_GEM_PATH) / "libexec" / "sorbet").to_s
|
9
|
+
WORKSPACE_PATH = (Pathname.new(ENV['BUNDLE_GEMFILE']) / "..").to_s
|
10
|
+
end
|
11
|
+
end
|
data/lib/spoom/sorbet.rb
ADDED
@@ -0,0 +1,70 @@
|
|
1
|
+
# typed: true
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require "spoom/sorbet/config"
|
5
|
+
require "spoom/sorbet/errors"
|
6
|
+
require "spoom/sorbet/lsp"
|
7
|
+
require "spoom/sorbet/metrics"
|
8
|
+
|
9
|
+
require "open3"
|
10
|
+
|
11
|
+
module Spoom
|
12
|
+
module Sorbet
|
13
|
+
extend T::Sig
|
14
|
+
|
15
|
+
sig { params(arg: String, path: String, capture_err: T::Boolean).returns([String, T::Boolean]) }
|
16
|
+
def self.srb(*arg, path: '.', capture_err: false)
|
17
|
+
opts = {}
|
18
|
+
opts[:chdir] = path
|
19
|
+
out = T.let("", T.nilable(String))
|
20
|
+
res = T.let(false, T::Boolean)
|
21
|
+
if capture_err
|
22
|
+
Open3.popen2e(["bundle", "exec", "srb", *arg].join(" "), opts) do |_, o, t|
|
23
|
+
out = o.read
|
24
|
+
res = T.cast(t.value, Process::Status).success?
|
25
|
+
end
|
26
|
+
else
|
27
|
+
Open3.popen2(["bundle", "exec", "srb", *arg].join(" "), opts) do |_, o, t|
|
28
|
+
out = o.read
|
29
|
+
res = T.cast(t.value, Process::Status).success?
|
30
|
+
end
|
31
|
+
end
|
32
|
+
[out || "", res]
|
33
|
+
end
|
34
|
+
|
35
|
+
sig { params(arg: String, path: String, capture_err: T::Boolean).returns([String, T::Boolean]) }
|
36
|
+
def self.srb_tc(*arg, path: '.', capture_err: false)
|
37
|
+
srb(*T.unsafe(["tc", *arg]), path: path, capture_err: capture_err)
|
38
|
+
end
|
39
|
+
|
40
|
+
# List all files typechecked by Sorbet from its `config`
|
41
|
+
sig { params(config: Config, path: String).returns(T::Array[String]) }
|
42
|
+
def self.srb_files(config, path: '.')
|
43
|
+
regs = config.ignore.map { |string| Regexp.new(Regexp.escape(string)) }
|
44
|
+
exts = config.allowed_extensions.empty? ? ['.rb', '.rbi'] : config.allowed_extensions
|
45
|
+
Dir.glob((Pathname.new(path) / "**/*{#{exts.join(',')}}").to_s).reject do |f|
|
46
|
+
regs.any? { |re| re.match?(f) }
|
47
|
+
end.sort
|
48
|
+
end
|
49
|
+
|
50
|
+
sig { params(arg: String, path: String, capture_err: T::Boolean).returns(T.nilable(String)) }
|
51
|
+
def self.srb_version(*arg, path: '.', capture_err: false)
|
52
|
+
out, res = srb(*T.unsafe(["--version", *arg]), path: path, capture_err: capture_err)
|
53
|
+
return nil unless res
|
54
|
+
out.split(" ")[2]
|
55
|
+
end
|
56
|
+
|
57
|
+
sig { params(arg: String, path: String, capture_err: T::Boolean).returns(T.nilable(Metrics)) }
|
58
|
+
def self.srb_metrics(*arg, path: '.', capture_err: false)
|
59
|
+
metrics_file = "metrics.tmp"
|
60
|
+
metrics_path = "#{path}/#{metrics_file}"
|
61
|
+
srb_tc(*T.unsafe(["--metrics-file=#{metrics_file}", *arg]), path: path, capture_err: capture_err)
|
62
|
+
if File.exist?(metrics_path)
|
63
|
+
metrics = Spoom::Sorbet::Metrics.parse_file(metrics_path)
|
64
|
+
File.delete(metrics_path)
|
65
|
+
return metrics
|
66
|
+
end
|
67
|
+
nil
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
data/lib/spoom/sorbet/config.rb
CHANGED
@@ -27,12 +27,13 @@ module Spoom
|
|
27
27
|
extend T::Sig
|
28
28
|
|
29
29
|
sig { returns(T::Array[String]) }
|
30
|
-
attr_reader :paths, :ignore
|
30
|
+
attr_reader :paths, :ignore, :allowed_extensions
|
31
31
|
|
32
32
|
sig { void }
|
33
33
|
def initialize
|
34
34
|
@paths = T.let([], T::Array[String])
|
35
35
|
@ignore = T.let([], T::Array[String])
|
36
|
+
@allowed_extensions = T.let([], T::Array[String])
|
36
37
|
end
|
37
38
|
|
38
39
|
class << self
|
@@ -46,13 +47,21 @@ module Spoom
|
|
46
47
|
sig { params(sorbet_config: String).returns(Spoom::Sorbet::Config) }
|
47
48
|
def parse_string(sorbet_config)
|
48
49
|
config = Config.new
|
49
|
-
|
50
|
-
skip = T.let(false, T::Boolean)
|
50
|
+
state = T.let(nil, T.nilable(Symbol))
|
51
51
|
sorbet_config.each_line do |line|
|
52
52
|
line = line.strip
|
53
53
|
case line
|
54
|
+
when /^--allowed-extension$/
|
55
|
+
state = :extension
|
56
|
+
next
|
57
|
+
when /^--allowed-extension=/
|
58
|
+
config.allowed_extensions << parse_option(line)
|
59
|
+
next
|
60
|
+
when /^--ignore=/
|
61
|
+
config.ignore << parse_option(line)
|
62
|
+
next
|
54
63
|
when /^--ignore$/
|
55
|
-
|
64
|
+
state = :ignore
|
56
65
|
next
|
57
66
|
when /^--ignore=/
|
58
67
|
config.ignore << parse_option(line)
|
@@ -70,18 +79,21 @@ module Spoom
|
|
70
79
|
when /^--.*=/
|
71
80
|
next
|
72
81
|
when /^--/
|
73
|
-
|
82
|
+
state = :skip
|
74
83
|
when /^-.*=?/
|
75
84
|
next
|
76
85
|
else
|
77
|
-
|
86
|
+
case state
|
87
|
+
when :ignore
|
78
88
|
config.ignore << line
|
79
|
-
|
80
|
-
|
81
|
-
|
89
|
+
when :extension
|
90
|
+
config.allowed_extensions << line
|
91
|
+
when :skip
|
92
|
+
# nothing
|
82
93
|
else
|
83
94
|
config.paths << line
|
84
95
|
end
|
96
|
+
state = nil
|
85
97
|
end
|
86
98
|
end
|
87
99
|
config
|
@@ -0,0 +1,129 @@
|
|
1
|
+
# typed: true
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module Spoom
|
5
|
+
module Sorbet
|
6
|
+
module Errors
|
7
|
+
# Parse errors from Sorbet output
|
8
|
+
class Parser
|
9
|
+
extend T::Sig
|
10
|
+
|
11
|
+
HEADER = [
|
12
|
+
"👋 Hey there! Heads up that this is not a release build of sorbet.",
|
13
|
+
"Release builds are faster and more well-supported by the Sorbet team.",
|
14
|
+
"Check out the README to learn how to build Sorbet in release mode.",
|
15
|
+
"To forcibly silence this error, either pass --silence-dev-message,",
|
16
|
+
"or set SORBET_SILENCE_DEV_MESSAGE=1 in your shell environment.",
|
17
|
+
]
|
18
|
+
|
19
|
+
sig { params(output: String).returns(T::Array[Error]) }
|
20
|
+
def self.parse_string(output)
|
21
|
+
parser = Spoom::Sorbet::Errors::Parser.new
|
22
|
+
parser.parse(output)
|
23
|
+
end
|
24
|
+
|
25
|
+
sig { void }
|
26
|
+
def initialize
|
27
|
+
@errors = []
|
28
|
+
@current_error = nil
|
29
|
+
end
|
30
|
+
|
31
|
+
sig { params(output: String).returns(T::Array[Error]) }
|
32
|
+
def parse(output)
|
33
|
+
output.each_line do |line|
|
34
|
+
break if /^No errors! Great job\./.match?(line)
|
35
|
+
break if /^Errors: /.match?(line)
|
36
|
+
next if HEADER.include?(line.strip)
|
37
|
+
|
38
|
+
next if line == "\n"
|
39
|
+
|
40
|
+
if leading_spaces(line) == 0
|
41
|
+
close_error if @current_error
|
42
|
+
open_error(line)
|
43
|
+
next
|
44
|
+
end
|
45
|
+
|
46
|
+
append_error(line) if @current_error
|
47
|
+
end
|
48
|
+
close_error if @current_error
|
49
|
+
@errors
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
sig { params(line: String).returns(T.nilable(Integer)) }
|
55
|
+
def leading_spaces(line)
|
56
|
+
line.index(/[^ ]/)
|
57
|
+
end
|
58
|
+
|
59
|
+
sig { params(line: String).void }
|
60
|
+
def open_error(line)
|
61
|
+
raise "Error: Already parsing an error!" if @current_error
|
62
|
+
@current_error = Error.from_error_line(line)
|
63
|
+
end
|
64
|
+
|
65
|
+
sig { void }
|
66
|
+
def close_error
|
67
|
+
raise "Error: Not already parsing an error!" unless @current_error
|
68
|
+
@errors << @current_error
|
69
|
+
@current_error = nil
|
70
|
+
end
|
71
|
+
|
72
|
+
sig { params(line: String).void }
|
73
|
+
def append_error(line)
|
74
|
+
raise "Error: Not already parsing an error!" unless @current_error
|
75
|
+
@current_error.more << line
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
class Error
|
80
|
+
include Comparable
|
81
|
+
extend T::Sig
|
82
|
+
|
83
|
+
sig { returns(T.nilable(String)) }
|
84
|
+
attr_reader :file, :message
|
85
|
+
|
86
|
+
sig { returns(T.nilable(Integer)) }
|
87
|
+
attr_reader :line, :code
|
88
|
+
|
89
|
+
sig { returns(T::Array[String]) }
|
90
|
+
attr_reader :more
|
91
|
+
|
92
|
+
sig do
|
93
|
+
params(
|
94
|
+
file: T.nilable(String),
|
95
|
+
line: T.nilable(Integer),
|
96
|
+
message: T.nilable(String),
|
97
|
+
code: T.nilable(Integer),
|
98
|
+
more: T::Array[String]
|
99
|
+
).void
|
100
|
+
end
|
101
|
+
def initialize(file, line, message, code, more = [])
|
102
|
+
@file = file
|
103
|
+
@line = line
|
104
|
+
@message = message
|
105
|
+
@code = code
|
106
|
+
@more = more
|
107
|
+
end
|
108
|
+
|
109
|
+
sig { params(line: String).returns(Error) }
|
110
|
+
def self.from_error_line(line)
|
111
|
+
file, line, rest = line.split(/: ?/, 3)
|
112
|
+
message, code = rest&.split(%r{ https://srb\.help/}, 2)
|
113
|
+
Error.new(file, line&.to_i, message, code&.to_i)
|
114
|
+
end
|
115
|
+
|
116
|
+
sig { params(other: T.untyped).returns(Integer) }
|
117
|
+
def <=>(other)
|
118
|
+
return 0 unless other.is_a?(Error)
|
119
|
+
[file, line, code, message] <=> [other.file, other.line, other.code, other.message]
|
120
|
+
end
|
121
|
+
|
122
|
+
sig { returns(String) }
|
123
|
+
def to_s
|
124
|
+
"#{file}:#{line}: #{message} (#{code})"
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
@@ -0,0 +1,194 @@
|
|
1
|
+
# typed: true
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require 'open3'
|
5
|
+
require 'json'
|
6
|
+
|
7
|
+
require_relative 'lsp/base'
|
8
|
+
require_relative 'lsp/structures'
|
9
|
+
require_relative 'lsp/errors'
|
10
|
+
|
11
|
+
module Spoom
|
12
|
+
module LSP
|
13
|
+
class Client
|
14
|
+
def initialize(sorbet_cmd, *sorbet_args)
|
15
|
+
@id = 0
|
16
|
+
Bundler.with_clean_env do
|
17
|
+
@in, @out, @err, @status = Open3.popen3([sorbet_cmd, *sorbet_args].join(" "))
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def next_id
|
22
|
+
@id += 1
|
23
|
+
end
|
24
|
+
|
25
|
+
def send_raw(json_string)
|
26
|
+
@in.puts("Content-Length:#{json_string.length}\r\n\r\n#{json_string}")
|
27
|
+
end
|
28
|
+
|
29
|
+
def send(message)
|
30
|
+
send_raw(message.to_json)
|
31
|
+
read if message.is_a?(Request)
|
32
|
+
end
|
33
|
+
|
34
|
+
def read_raw
|
35
|
+
header = @out.gets
|
36
|
+
|
37
|
+
# Sorbet returned an error and forgot to answer
|
38
|
+
raise Error::BadHeaders, "bad response headers" unless header&.match?(/Content-Length: /)
|
39
|
+
|
40
|
+
len = header.slice(::Range.new(16, nil)).to_i
|
41
|
+
@out.read(len + 2) # +2 'cause of the final \r\n
|
42
|
+
end
|
43
|
+
|
44
|
+
def read
|
45
|
+
json = JSON.parse(read_raw)
|
46
|
+
|
47
|
+
# Handle error in the LSP protocol
|
48
|
+
raise ResponseError.from_json(json['error']) if json['error']
|
49
|
+
|
50
|
+
# Handle typechecking errors
|
51
|
+
raise Error::Diagnostics.from_json(json['params']) if json['method'] == "textDocument/publishDiagnostics"
|
52
|
+
|
53
|
+
json
|
54
|
+
end
|
55
|
+
|
56
|
+
# LSP requests
|
57
|
+
|
58
|
+
def open(workspace_path)
|
59
|
+
raise Error::AlreadyOpen, "Error: CLI already opened" if @open
|
60
|
+
send(Request.new(
|
61
|
+
next_id,
|
62
|
+
'initialize',
|
63
|
+
{
|
64
|
+
'rootPath' => workspace_path,
|
65
|
+
'rootUri' => "file://#{workspace_path}",
|
66
|
+
'capabilities' => {},
|
67
|
+
},
|
68
|
+
))
|
69
|
+
send(Notification.new('initialized', {}))
|
70
|
+
@open = true
|
71
|
+
end
|
72
|
+
|
73
|
+
def hover(uri, line, column)
|
74
|
+
json = send(Request.new(
|
75
|
+
next_id,
|
76
|
+
'textDocument/hover',
|
77
|
+
{
|
78
|
+
'textDocument' => {
|
79
|
+
'uri' => uri,
|
80
|
+
},
|
81
|
+
'position' => {
|
82
|
+
'line' => line,
|
83
|
+
'character' => column,
|
84
|
+
},
|
85
|
+
}
|
86
|
+
))
|
87
|
+
return nil unless json['result']
|
88
|
+
Hover.from_json(json['result'])
|
89
|
+
end
|
90
|
+
|
91
|
+
def signatures(uri, line, column)
|
92
|
+
json = send(Request.new(
|
93
|
+
next_id,
|
94
|
+
'textDocument/signatureHelp',
|
95
|
+
{
|
96
|
+
'textDocument' => {
|
97
|
+
'uri' => uri,
|
98
|
+
},
|
99
|
+
'position' => {
|
100
|
+
'line' => line,
|
101
|
+
'character' => column,
|
102
|
+
},
|
103
|
+
}
|
104
|
+
))
|
105
|
+
json['result']['signatures'].map { |loc| SignatureHelp.from_json(loc) }
|
106
|
+
end
|
107
|
+
|
108
|
+
def definitions(uri, line, column)
|
109
|
+
json = send(Request.new(
|
110
|
+
next_id,
|
111
|
+
'textDocument/definition',
|
112
|
+
{
|
113
|
+
'textDocument' => {
|
114
|
+
'uri' => uri,
|
115
|
+
},
|
116
|
+
'position' => {
|
117
|
+
'line' => line,
|
118
|
+
'character' => column,
|
119
|
+
},
|
120
|
+
}
|
121
|
+
))
|
122
|
+
json['result'].map { |loc| Location.from_json(loc) }
|
123
|
+
end
|
124
|
+
|
125
|
+
def type_definitions(uri, line, column)
|
126
|
+
json = send(Request.new(
|
127
|
+
next_id,
|
128
|
+
'textDocument/typeDefinition',
|
129
|
+
{
|
130
|
+
'textDocument' => {
|
131
|
+
'uri' => uri,
|
132
|
+
},
|
133
|
+
'position' => {
|
134
|
+
'line' => line,
|
135
|
+
'character' => column,
|
136
|
+
},
|
137
|
+
}
|
138
|
+
))
|
139
|
+
json['result'].map { |loc| Location.from_json(loc) }
|
140
|
+
end
|
141
|
+
|
142
|
+
def references(uri, line, column, include_decl = true)
|
143
|
+
json = send(Request.new(
|
144
|
+
next_id,
|
145
|
+
'textDocument/references',
|
146
|
+
{
|
147
|
+
'textDocument' => {
|
148
|
+
'uri' => uri,
|
149
|
+
},
|
150
|
+
'position' => {
|
151
|
+
'line' => line,
|
152
|
+
'character' => column,
|
153
|
+
},
|
154
|
+
'context' => {
|
155
|
+
'includeDeclaration' => include_decl,
|
156
|
+
},
|
157
|
+
}
|
158
|
+
))
|
159
|
+
json['result'].map { |loc| Location.from_json(loc) }
|
160
|
+
end
|
161
|
+
|
162
|
+
def symbols(query)
|
163
|
+
json = send(Request.new(
|
164
|
+
next_id,
|
165
|
+
'workspace/symbol',
|
166
|
+
{
|
167
|
+
'query' => query,
|
168
|
+
}
|
169
|
+
))
|
170
|
+
json['result'].map { |loc| DocumentSymbol.from_json(loc) }
|
171
|
+
end
|
172
|
+
|
173
|
+
def document_symbols(uri)
|
174
|
+
json = send(Request.new(
|
175
|
+
next_id,
|
176
|
+
'textDocument/documentSymbol',
|
177
|
+
{
|
178
|
+
'textDocument' => {
|
179
|
+
'uri' => uri,
|
180
|
+
},
|
181
|
+
}
|
182
|
+
))
|
183
|
+
json['result'].map { |loc| DocumentSymbol.from_json(loc) }
|
184
|
+
end
|
185
|
+
|
186
|
+
def close
|
187
|
+
send(Request.new(next_id, "shutdown", nil))
|
188
|
+
@in.close
|
189
|
+
@out.close
|
190
|
+
@err.close
|
191
|
+
end
|
192
|
+
end
|
193
|
+
end
|
194
|
+
end
|