spoom 1.6.3 → 1.7.0

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,293 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Spoom
5
+ module Sorbet
6
+ module Translate
7
+ # Converts all `sig` nodes to RBS comments in the given Ruby code.
8
+ # It also handles type members and class annotations.
9
+ class SorbetSigsToRBSComments < Translator
10
+ #: (String, file: String, positional_names: bool) -> void
11
+ def initialize(ruby_contents, file:, positional_names:)
12
+ super(ruby_contents, file: file)
13
+
14
+ @positional_names = positional_names #: bool
15
+ @nesting = [] #: Array[Prism::ClassNode | Prism::ModuleNode | Prism::SingletonClassNode]
16
+ @last_sigs = [] #: Array[[Prism::CallNode, RBI::Sig]]
17
+ @type_members = [] #: Array[String]
18
+ end
19
+
20
+ # @override
21
+ #: (Prism::ClassNode) -> void
22
+ def visit_class_node(node)
23
+ visit_scope(node) { super }
24
+ end
25
+
26
+ # @override
27
+ #: (Prism::ModuleNode) -> void
28
+ def visit_module_node(node)
29
+ visit_scope(node) { super }
30
+ end
31
+
32
+ # @override
33
+ #: (Prism::SingletonClassNode) -> void
34
+ def visit_singleton_class_node(node)
35
+ visit_scope(node) { super }
36
+ end
37
+
38
+ # @override
39
+ #: (Prism::DefNode) -> void
40
+ def visit_def_node(node)
41
+ return if @last_sigs.empty?
42
+ return if @last_sigs.any? { |_, sig| sig.is_abstract }
43
+
44
+ apply_member_annotations(@last_sigs)
45
+
46
+ # Build the RBI::Method node so we can print the method signature as RBS.
47
+ builder = RBI::Parser::TreeBuilder.new(@ruby_contents, comments: [], file: @file)
48
+ builder.visit(node)
49
+ rbi_node = builder.tree.nodes.first #: as RBI::Method
50
+
51
+ @last_sigs.each do |node, sig|
52
+ out = StringIO.new
53
+ p = RBI::RBSPrinter.new(out: out, indent: node.location.start_column, positional_names: @positional_names)
54
+ p.print("#: ")
55
+ p.send(:print_method_sig, rbi_node, sig)
56
+ p.print("\n")
57
+ @rewriter << Source::Replace.new(node.location.start_offset, node.location.end_offset, out.string)
58
+ end
59
+
60
+ @last_sigs.clear
61
+ end
62
+
63
+ # @override
64
+ #: (Prism::CallNode) -> void
65
+ def visit_call_node(node)
66
+ case node.message
67
+ when "sig"
68
+ visit_sig(node)
69
+ when "attr_reader", "attr_writer", "attr_accessor"
70
+ visit_attr(node)
71
+ when "extend"
72
+ visit_extend(node)
73
+ when "abstract!", "interface!", "sealed!", "final!", "requires_ancestor"
74
+ visit_class_annotation(node)
75
+ else
76
+ super
77
+ end
78
+ end
79
+
80
+ # @override
81
+ #: (Prism::ConstantWriteNode) -> void
82
+ def visit_constant_write_node(node)
83
+ call = node.value
84
+ return super unless call.is_a?(Prism::CallNode)
85
+ return super unless call.message == "type_member"
86
+
87
+ @type_members << build_type_member_string(node)
88
+
89
+ from = adjust_to_line_start(node.location.start_offset)
90
+ to = adjust_to_line_end(node.location.end_offset)
91
+ to = adjust_to_new_line(to)
92
+
93
+ @rewriter << Source::Delete.new(from, to)
94
+ end
95
+
96
+ private
97
+
98
+ #: (Prism::ClassNode | Prism::ModuleNode | Prism::SingletonClassNode) { -> void } -> void
99
+ def visit_scope(node, &block)
100
+ @nesting << node
101
+ old_type_members = @type_members
102
+ @type_members = []
103
+
104
+ yield
105
+
106
+ if @type_members.any?
107
+ indent = " " * node.location.start_column
108
+ @rewriter << Source::Insert.new(node.location.start_offset, "#: [#{@type_members.join(", ")}]\n#{indent}")
109
+ end
110
+
111
+ @type_members = old_type_members
112
+ @nesting.pop
113
+ end
114
+
115
+ #: (Prism::CallNode) -> void
116
+ def visit_sig(node)
117
+ return unless sorbet_sig?(node)
118
+
119
+ builder = RBI::Parser::SigBuilder.new(@ruby_contents, file: @file)
120
+ builder.current.loc = node.location
121
+ builder.visit_call_node(node)
122
+ builder.current.comments = []
123
+
124
+ @last_sigs << [node, builder.current]
125
+ end
126
+
127
+ #: (Prism::CallNode) -> void
128
+ def visit_attr(node)
129
+ unless node.message == "attr_reader" || node.message == "attr_writer" || node.message == "attr_accessor"
130
+ raise Error, "Expected attr_reader, attr_writer, or attr_accessor"
131
+ end
132
+
133
+ return if @last_sigs.empty?
134
+ return if @last_sigs.any? { |_, sig| sig.is_abstract }
135
+
136
+ apply_member_annotations(@last_sigs)
137
+
138
+ builder = RBI::Parser::TreeBuilder.new(@ruby_contents, comments: [], file: @file)
139
+ builder.visit(node)
140
+ rbi_node = builder.tree.nodes.first #: as RBI::Attr
141
+
142
+ @last_sigs.each do |node, sig|
143
+ out = StringIO.new
144
+ p = RBI::RBSPrinter.new(out: out, indent: node.location.start_column, positional_names: @positional_names)
145
+ p.print("#: ")
146
+ p.print_attr_sig(rbi_node, sig)
147
+ p.print("\n")
148
+ @rewriter << Source::Replace.new(node.location.start_offset, node.location.end_offset, out.string)
149
+ end
150
+
151
+ @last_sigs.clear
152
+ end
153
+
154
+ #: (Prism::CallNode node) -> void
155
+ def visit_extend(node)
156
+ raise Error, "Expected extend" unless node.message == "extend"
157
+
158
+ return unless node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
159
+ return unless node.arguments&.arguments&.size == 1
160
+
161
+ arg = node.arguments&.arguments&.first
162
+ return unless arg.is_a?(Prism::ConstantPathNode)
163
+ return unless arg.slice.match?(/^(::)?T::Helpers$/) || arg.slice.match?(/^(::)?T::Generic$/)
164
+
165
+ from = adjust_to_line_start(node.location.start_offset)
166
+ to = adjust_to_line_end(node.location.end_offset)
167
+ to = adjust_to_new_line(to)
168
+ @rewriter << Source::Delete.new(from, to)
169
+ end
170
+
171
+ #: (Prism::CallNode node) -> void
172
+ def visit_class_annotation(node)
173
+ unless node.message == "abstract!" || node.message == "interface!" || node.message == "sealed!" ||
174
+ node.message == "final!" || node.message == "requires_ancestor"
175
+ raise Error, "Expected abstract!, interface!, sealed!, final!, or requires_ancestor"
176
+ end
177
+
178
+ return unless node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
179
+ return unless node.arguments.nil?
180
+
181
+ klass = @nesting.last #: as Prism::Node
182
+ indent = " " * klass.location.start_column
183
+
184
+ case node.message
185
+ when "abstract!"
186
+ @rewriter << Source::Insert.new(klass.location.start_offset, "# @abstract\n#{indent}")
187
+ when "interface!"
188
+ @rewriter << Source::Insert.new(klass.location.start_offset, "# @interface\n#{indent}")
189
+ when "sealed!"
190
+ @rewriter << Source::Insert.new(klass.location.start_offset, "# @sealed\n#{indent}")
191
+ when "final!"
192
+ @rewriter << Source::Insert.new(klass.location.start_offset, "# @final\n#{indent}")
193
+ when "requires_ancestor"
194
+ block = node.block
195
+ return unless block.is_a?(Prism::BlockNode)
196
+
197
+ body = block.body
198
+ return unless body.is_a?(Prism::StatementsNode)
199
+ return unless body.body.size == 1
200
+
201
+ arg = body.body.first #: as Prism::Node
202
+ srb_type = RBI::Type.parse_node(arg)
203
+ @rewriter << Source::Insert.new(klass.location.start_offset, "# @requires_ancestor: #{srb_type.rbs_string}\n#{indent}")
204
+ end
205
+
206
+ from = adjust_to_line_start(node.location.start_offset)
207
+ to = adjust_to_line_end(node.location.end_offset)
208
+ to = adjust_to_new_line(to)
209
+
210
+ @rewriter << Source::Delete.new(from, to)
211
+ end
212
+
213
+ #: (Array[[Prism::CallNode, RBI::Sig]]) -> void
214
+ def apply_member_annotations(sigs)
215
+ return if sigs.empty?
216
+
217
+ node, _sig = sigs.first #: as [Prism::CallNode, RBI::Sig]
218
+ insert_pos = node.location.start_offset
219
+
220
+ if sigs.any? { |_, sig| sig.without_runtime }
221
+ @rewriter << Source::Insert.new(insert_pos, "# @without_runtime\n")
222
+ end
223
+
224
+ if sigs.any? { |_, sig| sig.is_final }
225
+ @rewriter << Source::Insert.new(insert_pos, "# @final\n")
226
+ end
227
+
228
+ if sigs.any? { |_, sig| sig.is_abstract }
229
+ @rewriter << Source::Insert.new(insert_pos, "# @abstract\n")
230
+ end
231
+
232
+ if sigs.any? { |_, sig| sig.is_override }
233
+ @rewriter << if sigs.any? { |_, sig| sig.allow_incompatible_override }
234
+ Source::Insert.new(insert_pos, "# @override(allow_incompatible: true)\n")
235
+ else
236
+ Source::Insert.new(insert_pos, "# @override\n")
237
+ end
238
+ end
239
+
240
+ if sigs.any? { |_, sig| sig.is_overridable }
241
+ @rewriter << Source::Insert.new(insert_pos, "# @overridable\n")
242
+ end
243
+ end
244
+
245
+ #: (Prism::ConstantWriteNode) -> String
246
+ def build_type_member_string(node)
247
+ call = node.value
248
+ raise Error, "Expected a call node" unless call.is_a?(Prism::CallNode)
249
+ raise Error, "Expected type_member" unless call.message == "type_member"
250
+
251
+ type_member = node.name.to_s
252
+
253
+ arg = call.arguments&.arguments&.first
254
+ if arg.is_a?(Prism::SymbolNode)
255
+ case arg.slice
256
+ when ":in"
257
+ type_member = "in #{type_member}"
258
+ when ":out"
259
+ type_member = "out #{type_member}"
260
+ else
261
+ raise Error, "Unknown type member variance: #{arg.slice}"
262
+ end
263
+ end
264
+
265
+ block = call.block
266
+ return type_member unless block.is_a?(Prism::BlockNode)
267
+
268
+ body = block.body
269
+ return type_member unless body.is_a?(Prism::StatementsNode)
270
+ return type_member unless body.body.size == 1
271
+
272
+ hash = body.body.first
273
+ return type_member unless hash.is_a?(Prism::HashNode)
274
+
275
+ hash.elements.each do |element|
276
+ next unless element.is_a?(Prism::AssocNode)
277
+
278
+ type = RBI::Type.parse_node(element.value)
279
+
280
+ case element.key.slice
281
+ when "upper:"
282
+ type_member = "#{type_member} < #{type.rbs_string}"
283
+ when "fixed:"
284
+ type_member = "#{type_member} = #{type.rbs_string}"
285
+ end
286
+ end
287
+
288
+ type_member
289
+ end
290
+ end
291
+ end
292
+ end
293
+ end
@@ -0,0 +1,23 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Spoom
5
+ module Sorbet
6
+ module Translate
7
+ # Deletes all `sig` nodes from the given Ruby code.
8
+ # It doesn't handle type members and class annotations.
9
+ class StripSorbetSigs < Translator
10
+ # @override
11
+ #: (Prism::CallNode node) -> void
12
+ def visit_call_node(node)
13
+ return unless sorbet_sig?(node)
14
+
15
+ @rewriter << Source::Delete.new(
16
+ adjust_to_line_start(node.location.start_offset),
17
+ adjust_to_line_end(node.location.end_offset),
18
+ )
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,71 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Spoom
5
+ module Sorbet
6
+ module Translate
7
+ # @abstract
8
+ class Translator < Spoom::Visitor
9
+ #: (String, file: String) -> void
10
+ def initialize(ruby_contents, file:)
11
+ super()
12
+
13
+ @file = file #: String
14
+
15
+ @original_encoding = ruby_contents.encoding #: Encoding
16
+ @ruby_contents = if @original_encoding == "UTF-8"
17
+ ruby_contents
18
+ else
19
+ ruby_contents.encode("UTF-8")
20
+ end #: String
21
+
22
+ node = Spoom.parse_ruby(ruby_contents, file: file, comments: true)
23
+ @node = node #: Prism::Node
24
+ @ruby_bytes = ruby_contents.bytes #: Array[Integer]
25
+ @rewriter = Spoom::Source::Rewriter.new #: Source::Rewriter
26
+ end
27
+
28
+ #: -> String
29
+ def rewrite
30
+ visit(@node)
31
+ @rewriter.rewrite!(@ruby_bytes)
32
+ @ruby_bytes.pack("C*").force_encoding(@original_encoding)
33
+ end
34
+
35
+ private
36
+
37
+ #: (Prism::CallNode node) -> bool
38
+ def sorbet_sig?(node)
39
+ return false unless node.message == "sig"
40
+
41
+ recv = node.receiver
42
+ return false if recv && !recv.is_a?(Prism::SelfNode) && !recv.slice.match?(/(::)?T::Sig::WithoutRuntime/)
43
+
44
+ true
45
+ end
46
+
47
+ #: (Integer) -> Integer
48
+ def adjust_to_line_start(offset)
49
+ offset -= 1 while offset > 0 && @ruby_bytes[offset - 1] != "\n".ord
50
+ offset
51
+ end
52
+
53
+ #: (Integer) -> Integer
54
+ def adjust_to_line_end(offset)
55
+ offset += 1 while offset < @ruby_bytes.size && @ruby_bytes[offset] != "\n".ord
56
+ offset
57
+ end
58
+
59
+ # Consume the next blank line if any
60
+ #: (Integer) -> Integer
61
+ def adjust_to_new_line(offset)
62
+ if offset + 1 < @ruby_bytes.size && @ruby_bytes[offset + 1] == "\n".ord
63
+ offset += 1
64
+ end
65
+
66
+ offset
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,49 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "rbi"
5
+
6
+ require "spoom/source/rewriter"
7
+ require "spoom/sorbet/translate/translator"
8
+ require "spoom/sorbet/translate/rbs_comments_to_sorbet_sigs"
9
+ require "spoom/sorbet/translate/sorbet_assertions_to_rbs_comments"
10
+ require "spoom/sorbet/translate/sorbet_sigs_to_rbs_comments"
11
+ require "spoom/sorbet/translate/strip_sorbet_sigs"
12
+
13
+ module Spoom
14
+ module Sorbet
15
+ module Translate
16
+ class Error < Spoom::Error; end
17
+
18
+ class << self
19
+ # Deletes all `sig` nodes from the given Ruby code.
20
+ # It doesn't handle type members and class annotations.
21
+ #: (String ruby_contents, file: String) -> String
22
+ def strip_sorbet_sigs(ruby_contents, file:)
23
+ StripSorbetSigs.new(ruby_contents, file: file).rewrite
24
+ end
25
+
26
+ # Converts all `sig` nodes to RBS comments in the given Ruby code.
27
+ # It also handles type members and class annotations.
28
+ #: (String ruby_contents, file: String, ?positional_names: bool) -> String
29
+ def sorbet_sigs_to_rbs_comments(ruby_contents, file:, positional_names: true)
30
+ SorbetSigsToRBSComments.new(ruby_contents, file: file, positional_names: positional_names).rewrite
31
+ end
32
+
33
+ # Converts all the RBS comments in the given Ruby code to `sig` nodes.
34
+ # It also handles type members and class annotations.
35
+ #: (String ruby_contents, file: String) -> String
36
+ def rbs_comments_to_sorbet_sigs(ruby_contents, file:)
37
+ RBSCommentsToSorbetSigs.new(ruby_contents, file: file).rewrite
38
+ end
39
+
40
+ # Converts all `T.let` and `T.cast` nodes to RBS comments in the given Ruby code.
41
+ # It also handles type members and class annotations.
42
+ #: (String ruby_contents, file: String) -> String
43
+ def sorbet_assertions_to_rbs_comments(ruby_contents, file:)
44
+ SorbetAssertionsToRBSComments.new(ruby_contents, file: file).rewrite
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
data/lib/spoom/sorbet.rb CHANGED
@@ -1,12 +1,12 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- require "spoom/sorbet/assertions"
5
4
  require "spoom/sorbet/config"
6
5
  require "spoom/sorbet/errors"
7
6
  require "spoom/sorbet/lsp"
8
7
  require "spoom/sorbet/metrics"
9
8
  require "spoom/sorbet/sigils"
9
+ require "spoom/sorbet/translate"
10
10
 
11
11
  require "open3"
12
12
 
@@ -0,0 +1,167 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Spoom
5
+ # This module provides a simple API to rewrite source code.
6
+ #
7
+ # Using a `Rewriter`, you can build a list of changes to apply to a source file
8
+ # and apply them all at once. Edits are applied from bottom to top, so that the
9
+ # line numbers are not remapped after each edit.
10
+ #
11
+ # The source code is represented as an array of bytes, so that it can be
12
+ # manipulated in place. The client is responsible for `string <-> bytes`
13
+ # conversions and encoding handling.
14
+ #
15
+ # ```ruby
16
+ # bytes = "def foo; end".bytes
17
+ #
18
+ # rewriter = Spoom::Source::Rewriter.new
19
+ # rewriter << Spoom::Source::Replace.new(4, 6, "baz")
20
+ # rewriter << Spoom::Source::Insert.new(0, "def bar; end\n")
21
+ # rewriter.rewrite!(bytes)
22
+ #
23
+ # puts bytes.pack("C*") # => "def bar; end\ndef baz; end"
24
+ # ```
25
+ module Source
26
+ class PositionError < Spoom::Error; end
27
+
28
+ # @abstract
29
+ class Edit
30
+ # @abstract
31
+ #: (Array[Integer]) -> void
32
+ def apply(bytes); end
33
+
34
+ # @abstract
35
+ #: -> [Integer, Integer]
36
+ def range; end
37
+ end
38
+
39
+ class Insert < Edit
40
+ #: Integer
41
+ attr_reader :position
42
+
43
+ #: String
44
+ attr_reader :text
45
+
46
+ #: (Integer, String) -> void
47
+ def initialize(position, text)
48
+ super()
49
+
50
+ @position = position
51
+ @text = text
52
+ end
53
+
54
+ # @override
55
+ #: (Array[Integer]) -> void
56
+ def apply(bytes)
57
+ raise PositionError, "Position is out of bounds" if position < 0 || position > bytes.size
58
+
59
+ bytes #: untyped
60
+ .insert(position, *text.bytes)
61
+ end
62
+
63
+ # @override
64
+ #: -> [Integer, Integer]
65
+ def range
66
+ [position, position]
67
+ end
68
+
69
+ # @override
70
+ #: -> String
71
+ def to_s
72
+ "Insert `#{text}` at #{position}"
73
+ end
74
+ end
75
+
76
+ class Replace < Edit
77
+ #: Integer
78
+ attr_reader :from, :to
79
+
80
+ #: String
81
+ attr_reader :text
82
+
83
+ #: (Integer, Integer, String) -> void
84
+ def initialize(from, to, text)
85
+ super()
86
+
87
+ @from = from
88
+ @to = to
89
+ @text = text
90
+ end
91
+
92
+ # @override
93
+ #: (Array[Integer]) -> void
94
+ def apply(bytes)
95
+ raise PositionError, "Position is out of bounds" if from < 0 || to < 0 || from > bytes.size || to > bytes.size || from > to
96
+
97
+ bytes[from..to] = *text.bytes
98
+ end
99
+
100
+ # @override
101
+ #: -> [Integer, Integer]
102
+ def range
103
+ [from, to]
104
+ end
105
+
106
+ # @override
107
+ #: -> String
108
+ def to_s
109
+ "Replace #{from}-#{to} with `#{text}`"
110
+ end
111
+ end
112
+
113
+ class Delete < Edit
114
+ #: Integer
115
+ attr_reader :from, :to
116
+
117
+ #: (Integer, Integer) -> void
118
+ def initialize(from, to)
119
+ super()
120
+
121
+ @from = from
122
+ @to = to
123
+ end
124
+
125
+ # @override
126
+ #: (Array[untyped]) -> void
127
+ def apply(bytes)
128
+ raise PositionError, "Position is out of bounds" if from < 0 || to < 0 || from > bytes.size || to > bytes.size || from > to
129
+
130
+ bytes[from..to] = "".bytes
131
+ end
132
+
133
+ # @override
134
+ #: -> [Integer, Integer]
135
+ def range
136
+ [from, to]
137
+ end
138
+
139
+ # @override
140
+ #: -> String
141
+ def to_s
142
+ "Delete #{from}-#{to}"
143
+ end
144
+ end
145
+
146
+ class Rewriter
147
+ #: -> void
148
+ def initialize
149
+ @edits = [] #: Array[Edit]
150
+ end
151
+
152
+ #: (Edit) -> void
153
+ def <<(other)
154
+ @edits << other
155
+ end
156
+
157
+ #: (Array[Integer]) -> void
158
+ def rewrite!(bytes)
159
+ # To avoid remapping positions after each edit,
160
+ # we sort the changes by position and apply them in reverse order.
161
+ @edits.sort_by(&:range).reverse_each do |edit|
162
+ edit.apply(bytes)
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,4 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "source/rewriter"
data/lib/spoom/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Spoom
5
- VERSION = "1.6.3"
5
+ VERSION = "1.7.0"
6
6
  end
data/lib/spoom.rb CHANGED
@@ -15,7 +15,9 @@ require "spoom/context"
15
15
  require "spoom/colors"
16
16
  require "spoom/poset"
17
17
  require "spoom/model"
18
+ require "spoom/source"
18
19
  require "spoom/deadcode"
20
+ require "spoom/rbs"
19
21
  require "spoom/sorbet"
20
22
  require "spoom/cli"
21
23
  require "spoom/version"