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.
- checksums.yaml +4 -4
- data/lib/spoom/cli/srb/assertions.rb +1 -1
- data/lib/spoom/cli/srb/sigs.rb +8 -10
- data/lib/spoom/deadcode/index.rb +2 -2
- data/lib/spoom/deadcode/plugins/active_record.rb +19 -0
- data/lib/spoom/model/builder.rb +10 -15
- data/lib/spoom/model/model.rb +1 -1
- data/lib/spoom/parse.rb +4 -18
- data/lib/spoom/rbs.rb +77 -0
- data/lib/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs.rb +239 -0
- data/lib/spoom/sorbet/translate/sorbet_assertions_to_rbs_comments.rb +123 -0
- data/lib/spoom/sorbet/translate/sorbet_sigs_to_rbs_comments.rb +293 -0
- data/lib/spoom/sorbet/translate/strip_sorbet_sigs.rb +23 -0
- data/lib/spoom/sorbet/translate/translator.rb +71 -0
- data/lib/spoom/sorbet/translate.rb +49 -0
- data/lib/spoom/sorbet.rb +1 -1
- data/lib/spoom/source/rewriter.rb +167 -0
- data/lib/spoom/source.rb +4 -0
- data/lib/spoom/version.rb +1 -1
- data/lib/spoom.rb +2 -0
- data/rbi/spoom.rbi +241 -155
- metadata +25 -4
- data/lib/spoom/sorbet/assertions.rb +0 -278
- data/lib/spoom/sorbet/sigs.rb +0 -281
@@ -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
|
data/lib/spoom/source.rb
ADDED
data/lib/spoom/version.rb
CHANGED
data/lib/spoom.rb
CHANGED