spoom 1.0.3 → 1.0.4

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -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
@@ -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
- ignore = T.let(false, T::Boolean)
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
- ignore = true
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
- skip = true
82
+ state = :skip
74
83
  when /^-.*=?/
75
84
  next
76
85
  else
77
- if ignore
86
+ case state
87
+ when :ignore
78
88
  config.ignore << line
79
- ignore = false
80
- elsif skip
81
- skip = false
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