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.
@@ -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