crumbly 2.0.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 +7 -0
- data/CHANGELOG.md +68 -0
- data/LICENSE +21 -0
- data/README.md +455 -0
- data/exe/crumbly-convert +38 -0
- data/lib/crumbly/controller.rb +17 -0
- data/lib/crumbly/converter/crumb_definition.rb +476 -0
- data/lib/crumbly/converter/source.rb +70 -0
- data/lib/crumbly/converter.rb +288 -0
- data/lib/crumbly/crumb.rb +7 -0
- data/lib/crumbly/derived_call_sites.rb +50 -0
- data/lib/crumbly/errors.rb +15 -0
- data/lib/crumbly/json_ld.rb +51 -0
- data/lib/crumbly/level.rb +9 -0
- data/lib/crumbly/oracle.rb +147 -0
- data/lib/crumbly/railtie.rb +10 -0
- data/lib/crumbly/test_helper.rb +16 -0
- data/lib/crumbly/trail.rb +232 -0
- data/lib/crumbly/verifier.rb +201 -0
- data/lib/crumbly/version.rb +5 -0
- data/lib/crumbly/view_helpers.rb +30 -0
- data/lib/crumbly.rb +78 -0
- metadata +110 -0
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Crumbly
|
|
4
|
+
class Converter
|
|
5
|
+
# One gretel `crumb :key do |arguments| ... end`, written as the `def key(arguments)` of a Trail.
|
|
6
|
+
class CrumbDefinition
|
|
7
|
+
KEYWORDS = %i[
|
|
8
|
+
BEGIN END __ENCODING__ __FILE__ __LINE__ alias and begin break case class def defined? do else elsif end
|
|
9
|
+
ensure false for if in module next nil not or redo rescue retry return self super then true undef unless
|
|
10
|
+
until when while yield
|
|
11
|
+
].freeze
|
|
12
|
+
TRAIL_METHODS = %i[controller polymorphic_path polymorphic_url url_for url_options].freeze
|
|
13
|
+
NIL_TOLERANT_CALLS = %i[! nil? present? blank? presence].freeze
|
|
14
|
+
SINGLE_LINE_LENGTH = 90
|
|
15
|
+
|
|
16
|
+
# A local assigned outside the crumbs. A lambda at the top of the file that reads no other local becomes a
|
|
17
|
+
# private method of the Trail: `label.call(post)` reads `label(post)`. A proc keeps its lenient arity as a
|
|
18
|
+
# copied local.
|
|
19
|
+
Local = Data.define(:node, :source) do
|
|
20
|
+
def helper_method?
|
|
21
|
+
source.expansion_description.nil? && !function.nil? && (read_names(function.body) - scope_names).empty?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def method_lines
|
|
25
|
+
parameters = function.parameters&.parameters&.slice
|
|
26
|
+
body = function.body ? source.fragment(function.body, []).split("\n") : []
|
|
27
|
+
[parameters ? "def #{node.name}(#{parameters})" : "def #{node.name}", *body.map { |line| " #{line}" }, "end"]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def function
|
|
33
|
+
function = case node.value
|
|
34
|
+
in Prism::LambdaNode => lambda then lambda
|
|
35
|
+
in Prism::CallNode[receiver: nil, name: :lambda, block: Prism::BlockNode => block] then block
|
|
36
|
+
else return nil
|
|
37
|
+
end
|
|
38
|
+
function unless function.parameters in Prism::NumberedParametersNode | Prism::ItParametersNode
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def scope_names(node = function, names = [])
|
|
42
|
+
names.concat(node.locals) if node in Prism::LambdaNode | Prism::BlockNode
|
|
43
|
+
node&.compact_child_nodes&.each { |child| scope_names(child, names) }
|
|
44
|
+
names
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def read_names(node, names = [])
|
|
48
|
+
names << node.name if node.is_a?(Prism::LocalVariableReadNode)
|
|
49
|
+
node&.compact_child_nodes&.each { |child| read_names(child, names) }
|
|
50
|
+
names
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
attr_reader :key, :line, :notes
|
|
55
|
+
|
|
56
|
+
def initialize(call, source, local_by_name: {})
|
|
57
|
+
@call = call
|
|
58
|
+
@source = source
|
|
59
|
+
@local_by_name = local_by_name
|
|
60
|
+
@line = call.location.start_line
|
|
61
|
+
@notes = []
|
|
62
|
+
@copied_ranges = []
|
|
63
|
+
@key = crumb_key
|
|
64
|
+
@block = call.block
|
|
65
|
+
raise unconvertible("crumb :#{key} has no block") unless block.is_a?(Prism::BlockNode)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def lines
|
|
69
|
+
body = copied_local_lines + body_lines(statements_of(block.body), [], nil)
|
|
70
|
+
signature = parameters_source.empty? ? "def #{key}" : "def #{key}(#{parameters_source})"
|
|
71
|
+
[*loose_comments, signature, *indent(body), "end"]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
attr_reader :call, :source, :block, :copied_ranges, :local_by_name
|
|
77
|
+
|
|
78
|
+
# The locals assigned outside the block that it reads, and those they read, in the order of the file. A
|
|
79
|
+
# literal is written in place instead (`path = :posts_path` makes `public_send(path)` read `posts_path`).
|
|
80
|
+
def copied_local_lines
|
|
81
|
+
names = read_names(block) - shadowed_names
|
|
82
|
+
loop do
|
|
83
|
+
more_names = names.flat_map { |name| read_names(local_by_name[name]&.node&.value) } - names
|
|
84
|
+
more_names &= local_by_name.keys
|
|
85
|
+
break if more_names.empty?
|
|
86
|
+
|
|
87
|
+
names += more_names
|
|
88
|
+
end
|
|
89
|
+
copied_locals = local_by_name.slice(*local_by_name.keys.intersection(names)).values
|
|
90
|
+
copied_locals.reject { |local| local.helper_method? || literal?(local.node.value) }.flat_map do |local|
|
|
91
|
+
local.source.fragment(local.node, helper_edits(local.node)).split("\n")
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def helper_method_call(node)
|
|
96
|
+
return unless node in Prism::CallNode[name: :call | :[], receiver: Prism::LocalVariableReadNode => receiver]
|
|
97
|
+
return if shadowed_names.include?(receiver.name)
|
|
98
|
+
|
|
99
|
+
receiver.name if local_by_name[receiver.name]&.helper_method?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def helper_method_edits(call, name, edits)
|
|
103
|
+
arguments = call.arguments&.arguments || []
|
|
104
|
+
if call.opening_loc
|
|
105
|
+
edits << [call.location.start_offset, call.opening_loc.end_offset, "#{name}("]
|
|
106
|
+
edits << [call.closing_loc.start_offset, call.closing_loc.end_offset, ")"]
|
|
107
|
+
elsif arguments.empty?
|
|
108
|
+
return edits << [call.location.start_offset, call.location.end_offset, name.to_s]
|
|
109
|
+
else
|
|
110
|
+
edits << [call.location.start_offset, arguments.first.location.start_offset, "#{name}("]
|
|
111
|
+
edits << [call.location.end_offset, call.location.end_offset, ")"]
|
|
112
|
+
end
|
|
113
|
+
arguments.each { |argument| helper_edits(argument, edits) }
|
|
114
|
+
edits
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def literal?(node)
|
|
118
|
+
Converter::STATIC_NODES.any? { |node_class| node.is_a?(node_class) }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def inlined_literal(node)
|
|
122
|
+
return unless node.is_a?(Prism::LocalVariableReadNode) && !shadowed_names.include?(node.name)
|
|
123
|
+
|
|
124
|
+
value = local_by_name[node.name]&.node&.value
|
|
125
|
+
value if value && literal?(value)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# The names the crumb's block, and the blocks inside it, bind themselves: an outer local of that name is
|
|
129
|
+
# not read there.
|
|
130
|
+
def shadowed_names
|
|
131
|
+
@shadowed_names ||= scope_locals(block).uniq
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def scope_locals(node, names = [])
|
|
135
|
+
names.concat(node.locals) if node in Prism::LambdaNode | Prism::BlockNode
|
|
136
|
+
node.compact_child_nodes.each { |child| scope_locals(child, names) }
|
|
137
|
+
names
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def read_names(node, names = [])
|
|
141
|
+
names << node.name if node.is_a?(Prism::LocalVariableReadNode) && !names.include?(node.name)
|
|
142
|
+
node&.compact_child_nodes&.each { |child| read_names(child, names) }
|
|
143
|
+
names
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def crumb_key
|
|
147
|
+
argument = call.arguments&.arguments&.first
|
|
148
|
+
unless argument in Prism::SymbolNode | Prism::StringNode
|
|
149
|
+
raise unconvertible("crumb key #{argument&.slice.inspect} is not a literal symbol: left out")
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
name = argument.unescaped.to_sym
|
|
153
|
+
raise unconvertible("crumb :#{name} cannot be a method of a Trail: rename it") unless method_name?(name)
|
|
154
|
+
|
|
155
|
+
name
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def method_name?(name)
|
|
159
|
+
name.match?(/\A[a-z_][a-zA-Z0-9_]*[?!]?\z/) && !KEYWORDS.include?(name) &&
|
|
160
|
+
!Object.method_defined?(name) && !Trail.private_method_defined?(name)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def parameters_source
|
|
164
|
+
@parameters_source ||= block_parameters_source
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# gretel runs a crumb as a block, which accepts fewer arguments than it declares: a parameter the
|
|
168
|
+
# crumb tests for nil gets a nil default, and so do the ones after it.
|
|
169
|
+
def block_parameters_source
|
|
170
|
+
parameters = block.parameters
|
|
171
|
+
if parameters in Prism::NumberedParametersNode | Prism::ItParametersNode
|
|
172
|
+
raise unconvertible("crumb :#{key} uses numbered parameters: left out")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
parameters = parameters&.parameters
|
|
176
|
+
return "" unless parameters
|
|
177
|
+
|
|
178
|
+
nil_tested = nil_tested_names(block.body)
|
|
179
|
+
defaulted = false
|
|
180
|
+
requireds = parameters.requireds.map do |parameter|
|
|
181
|
+
defaulted ||= parameter.is_a?(Prism::RequiredParameterNode) && nil_tested.include?(parameter.name)
|
|
182
|
+
defaulted ? "#{parameter.name} = nil" : fragment(parameter)
|
|
183
|
+
end
|
|
184
|
+
others = [*parameters.optionals, parameters.rest, *parameters.posts, *parameters.keywords,
|
|
185
|
+
parameters.keyword_rest, parameters.block]
|
|
186
|
+
others = others.filter_map do |parameter|
|
|
187
|
+
fragment(parameter) unless parameter.nil? || parameter.is_a?(Prism::ImplicitRestNode)
|
|
188
|
+
end
|
|
189
|
+
[*requireds, *others].join(", ")
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def nil_tested_names(node, names = Set.new)
|
|
193
|
+
case node
|
|
194
|
+
when Prism::IfNode, Prism::UnlessNode, Prism::CaseNode then tested_names(node.predicate, names)
|
|
195
|
+
when Prism::CallNode
|
|
196
|
+
names << node.receiver.name if node.safe_navigation? && node.receiver.is_a?(Prism::LocalVariableReadNode)
|
|
197
|
+
end
|
|
198
|
+
node&.compact_child_nodes&.each { |child| nil_tested_names(child, names) }
|
|
199
|
+
names
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def tested_names(predicate, names)
|
|
203
|
+
case predicate
|
|
204
|
+
when Prism::LocalVariableReadNode then names << predicate.name
|
|
205
|
+
when Prism::CallNode
|
|
206
|
+
tested_names(predicate.receiver, names) if NIL_TOLERANT_CALLS.include?(predicate.name)
|
|
207
|
+
when Prism::AndNode, Prism::OrNode
|
|
208
|
+
tested_names(predicate.left, names)
|
|
209
|
+
tested_names(predicate.right, names)
|
|
210
|
+
when Prism::ParenthesesNode
|
|
211
|
+
predicate.body&.compact_child_nodes&.each { |child| tested_names(child, names) }
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# gretel adds a level per `link` and keeps the last `parent`, in any order: they become one `crumb`
|
|
216
|
+
# call, ending the method or each branch of a conditional that calls them, the statements after the
|
|
217
|
+
# conditional copied into each branch.
|
|
218
|
+
def body_lines(statements, links, parent)
|
|
219
|
+
lines = []
|
|
220
|
+
statements.each_with_index do |statement, index|
|
|
221
|
+
if crumb_dsl_call?(statement, :link)
|
|
222
|
+
links += [statement]
|
|
223
|
+
elsif crumb_dsl_call?(statement, :parent)
|
|
224
|
+
parent = parent_target(statement)
|
|
225
|
+
elsif (conditional_parent = conditional_parent_target(statement, parent))
|
|
226
|
+
parent = conditional_parent
|
|
227
|
+
elsif calls_crumb_dsl?(statement)
|
|
228
|
+
unless conditional?(statement)
|
|
229
|
+
raise unconvertible("crumb :#{key} calls link or parent inside #{summary(statement)}: left out, " \
|
|
230
|
+
"convert it by hand", statement)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
return lines + conditional_lines(statement, statements.drop(index + 1), links, parent)
|
|
234
|
+
else
|
|
235
|
+
lines.concat(fragment(statement).split("\n"))
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
lines + result_lines(links, parent)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def conditional_lines(node, rest, links, parent)
|
|
242
|
+
branch_lines = ->(statements) { body_lines(statements_of(statements) + rest, links, parent) }
|
|
243
|
+
case node
|
|
244
|
+
when Prism::UnlessNode
|
|
245
|
+
predicate = fragment(node.predicate)
|
|
246
|
+
negative_lines = branch_lines.call(node.statements)
|
|
247
|
+
positive_lines = branch_lines.call(node.else_clause&.statements)
|
|
248
|
+
return ["unless #{predicate}", *indent(negative_lines), "end"] if positive_lines.empty?
|
|
249
|
+
|
|
250
|
+
["if #{predicate}", *indent(positive_lines), "else", *indent(negative_lines), "end"]
|
|
251
|
+
when Prism::CaseNode
|
|
252
|
+
lines = [node.predicate ? "case #{fragment(node.predicate)}" : "case"]
|
|
253
|
+
node.conditions.each do |condition|
|
|
254
|
+
lines << "when #{condition.conditions.map { |value| fragment(value) }.join(", ")}"
|
|
255
|
+
lines.concat(indent(branch_lines.call(condition.statements)))
|
|
256
|
+
end
|
|
257
|
+
else_lines = branch_lines.call(node.else_clause&.statements)
|
|
258
|
+
lines.push("else", *indent(else_lines)) if else_lines.any?
|
|
259
|
+
lines << "end"
|
|
260
|
+
else
|
|
261
|
+
lines = ["if #{fragment(node.predicate)}", *indent(branch_lines.call(node.statements))]
|
|
262
|
+
subsequent = node.subsequent
|
|
263
|
+
while subsequent.is_a?(Prism::IfNode)
|
|
264
|
+
lines << "elsif #{fragment(subsequent.predicate)}"
|
|
265
|
+
lines.concat(indent(branch_lines.call(subsequent.statements)))
|
|
266
|
+
subsequent = subsequent.subsequent
|
|
267
|
+
end
|
|
268
|
+
else_lines = branch_lines.call(subsequent&.statements)
|
|
269
|
+
lines.push("else", *indent(else_lines)) if else_lines.any?
|
|
270
|
+
lines << "end"
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# `parent :a` in one branch and `parent :b` in the other: a single crumb whose parent is a ternary. A
|
|
275
|
+
# branch without a parent keeps the one set before the conditional, as gretel does.
|
|
276
|
+
def conditional_parent_target(node, previous_parent)
|
|
277
|
+
return unless node in Prism::IfNode | Prism::UnlessNode
|
|
278
|
+
|
|
279
|
+
else_node = node.is_a?(Prism::IfNode) ? node.subsequent : node.else_clause
|
|
280
|
+
return if else_node.is_a?(Prism::IfNode)
|
|
281
|
+
|
|
282
|
+
then_parent = single_parent_call(node.statements)
|
|
283
|
+
else_parent = single_parent_call(else_node&.statements)
|
|
284
|
+
return if then_parent.nil? || (else_node && else_parent.nil?)
|
|
285
|
+
|
|
286
|
+
predicate = fragment(node.predicate)
|
|
287
|
+
then_target = parent_target(then_parent)
|
|
288
|
+
else_target = else_parent ? parent_target(else_parent) : previous_parent
|
|
289
|
+
else_target = "(#{else_target})" if else_target&.include?(" ? ")
|
|
290
|
+
# A modifier runs its predicate first but is read after its body: a local the predicate assigns
|
|
291
|
+
# would be read as a method call.
|
|
292
|
+
else_target ||= "nil" if writes_local?(node.predicate)
|
|
293
|
+
condition = else_target && ternary_condition(node.predicate, predicate)
|
|
294
|
+
if node.is_a?(Prism::IfNode)
|
|
295
|
+
else_target ? "#{condition} ? #{then_target} : #{else_target}" : "(#{then_target} if #{predicate})"
|
|
296
|
+
else
|
|
297
|
+
else_target ? "#{condition} ? #{else_target} : #{then_target}" : "(#{then_target} unless #{predicate})"
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# `?:` binds tighter than `or`, `not` and `=`: `x or y ? a : b` would pass `x` as the parent.
|
|
302
|
+
def ternary_condition(predicate, text)
|
|
303
|
+
bare = (predicate in Prism::LocalVariableReadNode | Prism::InstanceVariableReadNode | Prism::ParenthesesNode |
|
|
304
|
+
Prism::CallNode)
|
|
305
|
+
bare &&= predicate.message != "not" if predicate.is_a?(Prism::CallNode)
|
|
306
|
+
bare ? text : "(#{text})"
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def writes_local?(node)
|
|
310
|
+
return true if node in Prism::LocalVariableWriteNode | Prism::LocalVariableOperatorWriteNode |
|
|
311
|
+
Prism::LocalVariableOrWriteNode | Prism::LocalVariableAndWriteNode | Prism::MultiWriteNode
|
|
312
|
+
|
|
313
|
+
node.compact_child_nodes.any? { |child| writes_local?(child) }
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def single_parent_call(statements)
|
|
317
|
+
body = statements_of(statements)
|
|
318
|
+
body.first if body.size == 1 && crumb_dsl_call?(body.first, :parent)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def parent_target(call)
|
|
322
|
+
arguments = call.arguments&.arguments || []
|
|
323
|
+
raise unconvertible("crumb :#{key} reads its parent instead of setting it: left out", call) if arguments.empty?
|
|
324
|
+
|
|
325
|
+
target, *rest = arguments
|
|
326
|
+
target = inlined_literal(target) || target
|
|
327
|
+
rest = rest.map { |argument| fragment(argument) }
|
|
328
|
+
case target
|
|
329
|
+
in Prism::SymbolNode | Prism::StringNode
|
|
330
|
+
name = target.unescaped.to_sym
|
|
331
|
+
raise unconvertible("parent :#{name} cannot be a method of a Trail: rename it", call) unless method_name?(name)
|
|
332
|
+
|
|
333
|
+
crumb_method_call(name, rest)
|
|
334
|
+
in Prism::InterpolatedSymbolNode | Prism::CallNode[name: :to_sym]
|
|
335
|
+
"public_send(#{[fragment(target), *rest].join(", ")})"
|
|
336
|
+
else
|
|
337
|
+
"crumb_for(#{[fragment(target), *rest].join(", ")})"
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def crumb_method_call(name, arguments)
|
|
342
|
+
return "#{name}(#{arguments.join(", ")})" if arguments.any?
|
|
343
|
+
|
|
344
|
+
block.locals.include?(name) ? "self.#{name}" : name.to_s
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def result_lines(links, parent)
|
|
348
|
+
return parent ? parent.split("\n") : [] if links.empty?
|
|
349
|
+
|
|
350
|
+
(label, url), *other_links = links.map { |link| link_sources(link) }
|
|
351
|
+
arguments = [label, url].compact
|
|
352
|
+
keywords = []
|
|
353
|
+
keywords << "parent: #{parent}" if parent
|
|
354
|
+
if other_links.any?
|
|
355
|
+
keywords << "links: [#{other_links.map { |link_label, link_url| "link(#{link_label}, #{link_url || "nil"})" }.join(", ")}]"
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
single_line = "crumb #{[*arguments, *keywords].join(", ")}"
|
|
359
|
+
return single_line.split("\n") if single_line.length <= SINGLE_LINE_LENGTH || keywords.empty?
|
|
360
|
+
|
|
361
|
+
["crumb #{arguments.join(", ")},", *keywords.map { |keyword| " #{keyword}" }.join(",\n").split("\n")]
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def link_sources(link)
|
|
365
|
+
arguments = link.arguments&.arguments.to_a.dup
|
|
366
|
+
if arguments.size > 1 && arguments.last.is_a?(Prism::KeywordHashNode)
|
|
367
|
+
options = arguments.pop
|
|
368
|
+
notes << "crumb :#{key}: link options #{options.slice} have no equivalent in Crumbly: left out"
|
|
369
|
+
end
|
|
370
|
+
if arguments.empty? || arguments.size > 2 || link.block ||
|
|
371
|
+
arguments.any? { |argument| argument in Prism::SplatNode | Prism::KeywordHashNode }
|
|
372
|
+
raise unconvertible("crumb :#{key} calls #{summary(link)}: left out, convert it by hand", link)
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
label, url = arguments
|
|
376
|
+
[fragment(label), (url unless url.is_a?(Prism::NilNode)) && fragment(url)]
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def crumb_dsl_call?(node, name)
|
|
380
|
+
node in Prism::CallNode[receiver: nil, name: ^name, block: nil]
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def calls_crumb_dsl?(node)
|
|
384
|
+
return true if node in Prism::CallNode[receiver: nil, name: :link | :parent]
|
|
385
|
+
|
|
386
|
+
node.compact_child_nodes.any? { |child| calls_crumb_dsl?(child) }
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def conditional?(node)
|
|
390
|
+
node in Prism::IfNode | Prism::UnlessNode | Prism::CaseNode
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def statements_of(node)
|
|
394
|
+
case node
|
|
395
|
+
when nil then []
|
|
396
|
+
when Prism::StatementsNode then node.body
|
|
397
|
+
else raise unconvertible("crumb :#{key} uses begin, rescue or ensure: left out, convert it by hand", node)
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# A gretel crumb reaches the view through method_missing; a Trail reaches it through `helpers`.
|
|
402
|
+
def fragment(node)
|
|
403
|
+
copied_ranges << (node.location.start_offset...node.location.end_offset)
|
|
404
|
+
source.fragment(node, helper_edits(node))
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def helper_edits(node, edits = [])
|
|
408
|
+
if (literal = inlined_literal(node))
|
|
409
|
+
return edits << [node.location.start_offset, node.location.end_offset, literal.slice]
|
|
410
|
+
end
|
|
411
|
+
if (helper_method = helper_method_call(node))
|
|
412
|
+
return helper_method_edits(node, helper_method, edits)
|
|
413
|
+
end
|
|
414
|
+
if node.is_a?(Prism::LocalVariableReadNode) && !shadowed_names.include?(node.name) &&
|
|
415
|
+
local_by_name[node.name]&.helper_method?
|
|
416
|
+
return edits << [node.location.start_offset, node.location.end_offset, "method(:#{node.name})"]
|
|
417
|
+
end
|
|
418
|
+
if (method_name = static_send_name(node))
|
|
419
|
+
arguments = node.arguments.arguments.drop(1)
|
|
420
|
+
return edits << [node.location.start_offset, node.location.end_offset, method_name] if arguments.empty?
|
|
421
|
+
|
|
422
|
+
edits << [node.location.start_offset, arguments.first.location.start_offset, "#{method_name}("]
|
|
423
|
+
edits << [node.location.end_offset, node.location.end_offset, ")"] unless node.opening_loc
|
|
424
|
+
arguments.each { |argument| helper_edits(argument, edits) }
|
|
425
|
+
return edits
|
|
426
|
+
end
|
|
427
|
+
if node.is_a?(Prism::CallNode) && view_helper_call?(node)
|
|
428
|
+
edits << [node.location.start_offset, node.location.start_offset, "helpers."]
|
|
429
|
+
end
|
|
430
|
+
node.compact_child_nodes.each { |child| helper_edits(child, edits) }
|
|
431
|
+
edits
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# `send("admin_#{name}_path", post)` in a loop reads `admin_posts_path(post)` once the loop is expanded.
|
|
435
|
+
def static_send_name(node)
|
|
436
|
+
return unless node in Prism::CallNode[receiver: nil, name: :send | :public_send, block: nil,
|
|
437
|
+
arguments: {arguments: [target, *]}]
|
|
438
|
+
|
|
439
|
+
target = inlined_literal(target) || target
|
|
440
|
+
return unless target in Prism::StringNode | Prism::SymbolNode
|
|
441
|
+
|
|
442
|
+
name = target.unescaped
|
|
443
|
+
name if name.end_with?("_path", "_url") && name.match?(/\A[a-z_][a-z0-9_]*\z/)
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def view_helper_call?(call)
|
|
447
|
+
name = call.name
|
|
448
|
+
call.receiver.nil? && !%i[link parent].include?(name) && !name.end_with?("_path", "_url") &&
|
|
449
|
+
!TRAIL_METHODS.include?(name) && !Kernel.method_defined?(name) && !Kernel.private_method_defined?(name)
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def loose_comments
|
|
453
|
+
source.comments.filter_map do |comment|
|
|
454
|
+
offset = comment.location.start_offset
|
|
455
|
+
next unless offset.between?(block.location.start_offset, block.location.end_offset)
|
|
456
|
+
next if copied_ranges.any? { |range| range.cover?(offset) }
|
|
457
|
+
|
|
458
|
+
comment.slice.rstrip
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def indent(lines)
|
|
463
|
+
lines.map { |line| line.empty? ? line : " #{line}" }
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def summary(node)
|
|
467
|
+
first_line = node.slice.lines.first.strip
|
|
468
|
+
(first_line.length > 40) ? "#{first_line[0, 37]}..." : first_line
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def unconvertible(message, node = call)
|
|
472
|
+
Unconvertible.new(message, node.location.start_line)
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Crumbly
|
|
4
|
+
class Converter
|
|
5
|
+
# A parsed breadcrumbs file, or one expansion of a loop in it, and the edits that turn its nodes into text.
|
|
6
|
+
class Source
|
|
7
|
+
MAGIC_COMMENT = /\A#\s*(frozen_string_literal|encoding|coding|warn_indent|shareable_constant_value):/
|
|
8
|
+
|
|
9
|
+
attr_reader :path, :start_offset, :expansion_description
|
|
10
|
+
|
|
11
|
+
def self.parse(text, path:, start_offset: 0, expansion_description: nil, local_names: [])
|
|
12
|
+
result = Prism.parse(text, scopes: [local_names])
|
|
13
|
+
if result.failure?
|
|
14
|
+
error = result.errors.first
|
|
15
|
+
raise ArgumentError, "#{path}:#{error.location.start_line}: #{error.message}"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
new(text, result, path:, start_offset:, expansion_description:)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(text, result, path:, start_offset:, expansion_description:)
|
|
22
|
+
@text = text
|
|
23
|
+
@result = result
|
|
24
|
+
@path = path
|
|
25
|
+
@start_offset = start_offset
|
|
26
|
+
@expansion_description = expansion_description
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def statements
|
|
30
|
+
@result.value.statements.body
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def comments
|
|
34
|
+
@comments ||= @result.comments.reject { |comment| comment.slice.match?(MAGIC_COMMENT) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def comment_lines(from_offset, to_offset)
|
|
38
|
+
comments.filter_map do |comment|
|
|
39
|
+
comment.slice.rstrip if comment.location.start_offset.between?(from_offset, to_offset - 1)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Blanks everything before the expanded text, so that its lines and columns stay those of this file.
|
|
44
|
+
def expansion(expanded_text, start_offset, description, local_names)
|
|
45
|
+
padding = @text.byteslice(0, start_offset).gsub(/[^\n]/) { |character| " " * character.bytesize }
|
|
46
|
+
self.class.parse(padding + expanded_text, path:, start_offset:, expansion_description: description,
|
|
47
|
+
local_names:)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def rewrite(from_offset, to_offset, edits)
|
|
51
|
+
rewritten = +""
|
|
52
|
+
offset = from_offset
|
|
53
|
+
edits.sort_by(&:first).each do |edit_start, edit_end, replacement|
|
|
54
|
+
rewritten << @text.byteslice(offset, edit_start - offset) << replacement
|
|
55
|
+
offset = edit_end
|
|
56
|
+
end
|
|
57
|
+
rewritten << @text.byteslice(offset, to_offset - offset)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The text of a node with its continuation lines dedented, as if the node started at column 0.
|
|
61
|
+
def fragment(node, edits)
|
|
62
|
+
location = node.location
|
|
63
|
+
text = rewrite(location.start_offset, location.end_offset, edits)
|
|
64
|
+
first_line, *other_lines = text.split("\n", -1)
|
|
65
|
+
dedent = /\A {0,#{location.start_column}}/
|
|
66
|
+
[first_line, *other_lines.map { |line| line.sub(dedent, "") }].join("\n")
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|