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