spoom 1.0.3 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|