rubycli 0.1.7 → 0.2.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.
@@ -1,27 +1,62 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'ripper'
4
+
3
5
  module Rubycli
4
6
  # Observes constants defined while loading a file.
5
7
  class ConstantCapture
6
8
  def initialize
7
9
  @captured = Hash.new { |hash, key| hash[key] = [] }
10
+ @assignment_definitions = {}
8
11
  end
9
12
 
10
13
  def capture(file)
11
- trace = TracePoint.new(:class) do |tp|
14
+ normalized_file = normalize(file)
15
+ source = File.read(normalized_file)
16
+ previous_names = @captured[normalized_file].dup
17
+ previous_assignment_definitions = @assignment_definitions.fetch(normalized_file, {})
18
+ executed_line_contexts = Hash.new { |hash, line| hash[line] = [] }
19
+ assignment_events = Hash.new { |hash, line| hash[line] = [] }
20
+ observed_events = []
21
+ @captured[normalized_file] = []
22
+ before_snapshot = constant_snapshot(normalized_file)
23
+ trace = TracePoint.new(:class, :line, :call, :return, :b_call, :b_return, :c_call, :c_return) do |tp|
12
24
  location = tp.path
13
- next unless location && same_file?(file, location)
14
-
15
- constant_name = qualified_name_for(tp.self)
16
- next unless constant_name
25
+ next unless location && same_file?(normalized_file, location)
17
26
 
18
- @captured[file] << constant_name
27
+ observed_events << [tp.event, tp.self, tp.lineno, tp.method_id]
19
28
  end
20
29
 
21
30
  trace.enable
22
31
  yield
23
32
  ensure
24
33
  trace&.disable
34
+ if normalized_file && before_snapshot
35
+ current_assignment_definitions = assigned_constant_definitions(source)
36
+ apply_trace_events(
37
+ observed_events,
38
+ executed_line_contexts,
39
+ assignment_events,
40
+ normalized_file
41
+ )
42
+ after_snapshot = constant_snapshot(normalized_file)
43
+ changed_names = after_snapshot.keys.select do |name|
44
+ before_snapshot[name] != after_snapshot[name]
45
+ end
46
+ retained_names = previous_names.select do |name|
47
+ previous_definitions = previous_assignment_definitions.fetch(name, [])
48
+ current_definitions = current_assignment_definitions.fetch(name, [])
49
+ definition_active = active_definition_retained?(
50
+ previous_definitions,
51
+ current_definitions,
52
+ executed_line_contexts,
53
+ assignment_events
54
+ )
55
+ after_snapshot.key?(name) && definition_active
56
+ end
57
+ @captured[normalized_file].concat(changed_names).concat(retained_names)
58
+ @assignment_definitions[normalized_file] = current_assignment_definitions
59
+ end
25
60
  end
26
61
 
27
62
  def constants_for(file)
@@ -46,5 +81,527 @@ module Rubycli
46
81
 
47
82
  name
48
83
  end
84
+
85
+ def constant_snapshot(file)
86
+ ObjectSpace.each_object(Module).each_with_object({}) do |owner, snapshot|
87
+ owner_name = owner.equal?(Object) ? '' : owner.name
88
+ next if owner_name.nil? || owner_name.start_with?('#<')
89
+
90
+ safe_module_constants(owner).each do |constant_name|
91
+ next if safe_autoload?(owner, constant_name)
92
+
93
+ location = safe_const_source_location(owner, constant_name)
94
+ next unless location && same_file?(file, location[0])
95
+
96
+ value = safe_const_get(owner, constant_name)
97
+ next unless value.is_a?(Module)
98
+
99
+ name = qualified_constant_name(owner_name, constant_name)
100
+ snapshot[name] = [location, value.object_id]
101
+ end
102
+ end
103
+ rescue StandardError
104
+ {}
105
+ end
106
+
107
+ def safe_module_constants(owner)
108
+ owner.constants(false)
109
+ rescue StandardError
110
+ []
111
+ end
112
+
113
+ def safe_autoload?(owner, constant_name)
114
+ owner.autoload?(constant_name, false)
115
+ rescue StandardError
116
+ false
117
+ end
118
+
119
+ def safe_const_source_location(owner, constant_name)
120
+ owner.const_source_location(constant_name, false)
121
+ rescue StandardError
122
+ nil
123
+ end
124
+
125
+ def safe_const_get(owner, constant_name)
126
+ owner.const_get(constant_name, false)
127
+ rescue StandardError
128
+ nil
129
+ end
130
+
131
+ def qualified_constant_name(owner_name, constant_name)
132
+ return constant_name.to_s if owner_name.empty?
133
+
134
+ "#{owner_name}::#{constant_name}"
135
+ end
136
+
137
+ def apply_trace_events(events, executed_line_contexts, assignment_events, file)
138
+ active_context_events = []
139
+ events.each do |event, target, line, method_id|
140
+ case event
141
+ when :line
142
+ executed_line_contexts[line] << active_context_events.dup
143
+ when :call, :b_call
144
+ active_context_events << event
145
+ when :return
146
+ remove_active_context_event(active_context_events, :call)
147
+ when :b_return
148
+ remove_active_context_event(active_context_events, :b_call)
149
+ when :c_call
150
+ if method_id == :const_added || (method_id == :warn && target.equal?(Warning))
151
+ assignment_events[line] << method_id
152
+ end
153
+ when :c_return
154
+ @captured[file].concat(constants_set_at(target, file, line)) if method_id == :const_set
155
+ when :class
156
+ constant_name = qualified_name_for(target)
157
+ @captured[file] << constant_name if constant_name
158
+ end
159
+ end
160
+ end
161
+
162
+ def remove_active_context_event(active_context_events, event)
163
+ index = active_context_events.rindex(event)
164
+ active_context_events.delete_at(index) if index
165
+ end
166
+
167
+ def constants_set_at(owner, file, line)
168
+ owner_name = owner.equal?(Object) ? '' : owner.name
169
+ return [] if owner_name.nil? || owner_name.start_with?('#<')
170
+
171
+ safe_module_constants(owner).filter_map do |constant_name|
172
+ location = safe_const_source_location(owner, constant_name)
173
+ next unless location && same_file?(file, location[0]) && location[1] == line
174
+
175
+ value = safe_const_get(owner, constant_name)
176
+ next unless value.is_a?(Module)
177
+
178
+ qualified_constant_name(owner_name, constant_name)
179
+ end
180
+ end
181
+
182
+ def active_definition_retained?(
183
+ previous_definitions,
184
+ current_definitions,
185
+ executed_line_contexts,
186
+ assignment_events
187
+ )
188
+ previous_definitions.any? do |previous|
189
+ current_definitions.any? do |current|
190
+ next false unless previous[:signature] == current[:signature]
191
+
192
+ events = assignment_events[current[:line]]
193
+ assignment_observed = events.include?(:const_added) || events.count(:warn) >= 2
194
+ persistence_observed = current[:persistence_line] &&
195
+ !current[:persistence_ambiguous] &&
196
+ executed_line_contexts[current[:persistence_line]].any? do |active_context_events|
197
+ context_requirements_met?(
198
+ active_context_events,
199
+ current[:required_context_events]
200
+ )
201
+ end
202
+ assignment_observed || persistence_observed
203
+ end
204
+ end
205
+ end
206
+
207
+ def context_requirements_met?(active_context_events, required_context_events)
208
+ required_context_events.uniq.all? do |event|
209
+ active_context_events.count(event) >= required_context_events.count(event)
210
+ end
211
+ end
212
+
213
+ def assigned_constant_definitions(source)
214
+ syntax_tree = Ripper.sexp(source)
215
+ return {} unless syntax_tree
216
+
217
+ collect_assigned_constant_definitions(syntax_tree, [], [], {})
218
+ end
219
+
220
+ def collect_assigned_constant_definitions(node, namespace, contexts, definitions)
221
+ return definitions unless node.is_a?(Array)
222
+
223
+ case node.first
224
+ when :module
225
+ nested_name = constant_name_from_node(node[1], namespace)
226
+ collect_assigned_constant_definitions(node[2], Array(nested_name&.split('::')), contexts, definitions)
227
+ when :class
228
+ nested_name = constant_name_from_node(node[1], namespace)
229
+ collect_assigned_constant_definitions(node[3], Array(nested_name&.split('::')), contexts, definitions)
230
+ when :assign, :opassign
231
+ signature = [node.first, assignment_operator(node), contexts.map(&:first)]
232
+ collect_assignment_targets(node[1], namespace, definitions, signature, contexts)
233
+ node.drop(2).each do |child|
234
+ collect_assigned_constant_definitions(child, namespace, contexts, definitions)
235
+ end
236
+ when :massign
237
+ signature = [node.first, nil, contexts.map(&:first)]
238
+ collect_assignment_targets(node[1], namespace, definitions, signature, contexts)
239
+ node.drop(2).each do |child|
240
+ collect_assigned_constant_definitions(child, namespace, contexts, definitions)
241
+ end
242
+ when :method_add_arg, :command_call, :command
243
+ collect_const_set_definition(node, namespace, contexts, definitions)
244
+ node.each do |child|
245
+ collect_assigned_constant_definitions(child, namespace, contexts, definitions)
246
+ end
247
+ when :if, :unless
248
+ collect_assigned_constant_definitions(node[1], namespace, contexts, definitions)
249
+ condition = canonical_syntax(node[1])
250
+ condition_line = source_line(node[1])
251
+ then_context = contexts + [[[node.first, :then, condition], condition_line]]
252
+ else_context = contexts + [[[node.first, :else, condition], condition_line]]
253
+ collect_assigned_constant_definitions(node[2], namespace, then_context, definitions)
254
+ collect_assigned_constant_definitions(node[3], namespace, else_context, definitions)
255
+ when :while, :until
256
+ collect_assigned_constant_definitions(node[1], namespace, contexts, definitions)
257
+ body_context = contexts + [[[node.first, canonical_syntax(node[1])], source_line(node[1])]]
258
+ collect_assigned_constant_definitions(node[2], namespace, body_context, definitions)
259
+ when :if_mod, :unless_mod, :while_mod, :until_mod
260
+ collect_assigned_constant_definitions(node[1], namespace, contexts, definitions)
261
+ branch_context = contexts + [[[node.first, canonical_syntax(node[1])], source_line(node[1])]]
262
+ collect_assigned_constant_definitions(node[2], namespace, branch_context, definitions)
263
+ when :ifop
264
+ collect_assigned_constant_definitions(node[1], namespace, contexts, definitions)
265
+ condition = canonical_syntax(node[1])
266
+ condition_line = source_line(node[1])
267
+ then_context = contexts + [[[node.first, :then, condition], condition_line]]
268
+ else_context = contexts + [[[node.first, :else, condition], condition_line]]
269
+ collect_assigned_constant_definitions(node[2], namespace, then_context, definitions)
270
+ collect_assigned_constant_definitions(node[3], namespace, else_context, definitions)
271
+ when :binary
272
+ collect_assigned_constant_definitions(node[1], namespace, contexts, definitions)
273
+ right_contexts = contexts
274
+ if %i[&& ||].include?(node[2])
275
+ right_contexts += [[[node.first, node[2], canonical_syntax(node[1])], source_line(node[1])]]
276
+ end
277
+ collect_assigned_constant_definitions(node[3], namespace, right_contexts, definitions)
278
+ when :case
279
+ collect_assigned_constant_definitions(node[1], namespace, contexts, definitions)
280
+ case_context = contexts + [[[node.first, canonical_syntax(node[1])], source_line(node[1])]]
281
+ collect_assigned_constant_definitions(node[2], namespace, case_context, definitions)
282
+ when :for
283
+ collect_assigned_constant_definitions(node[2], namespace, contexts, definitions)
284
+ body_context = contexts + [[[node.first, canonical_syntax(node[2])], source_line(node[1])]]
285
+ collect_assigned_constant_definitions(node[3], namespace, body_context, definitions)
286
+ when :method_add_block
287
+ collect_assigned_constant_definitions(node[1], namespace, contexts, definitions)
288
+ lazy_context = contexts + [[[:block], source_line(node[1]), :b_call]]
289
+ collect_assigned_constant_definitions(node[2], namespace, lazy_context, definitions)
290
+ when :def, :defs
291
+ lazy_context = contexts + [[[node.first], source_line(node), :call]]
292
+ node.drop(1).each do |child|
293
+ collect_assigned_constant_definitions(child, namespace, lazy_context, definitions)
294
+ end
295
+ when :lambda
296
+ lazy_context = contexts + [[[node.first], source_line(node), :b_call]]
297
+ node.drop(1).each do |child|
298
+ collect_assigned_constant_definitions(child, namespace, lazy_context, definitions)
299
+ end
300
+ when :do_block, :brace_block
301
+ node.drop(1).each do |child|
302
+ collect_assigned_constant_definitions(child, namespace, contexts, definitions)
303
+ end
304
+ else
305
+ node.each do |child|
306
+ collect_assigned_constant_definitions(child, namespace, contexts, definitions)
307
+ end
308
+ end
309
+
310
+ definitions
311
+ end
312
+
313
+ def collect_assignment_targets(node, namespace, definitions, signature, contexts)
314
+ assigned_name = constant_name_from_node(node, namespace)
315
+ if assigned_name
316
+ line = source_line(node)
317
+ persistence = persistence_metadata(
318
+ contexts,
319
+ assigned_name,
320
+ namespace,
321
+ line,
322
+ signature[1] == '||='
323
+ )
324
+ definition = {
325
+ signature: signature,
326
+ line: line,
327
+ persistence_line: persistence[:line],
328
+ persistence_ambiguous: persistence[:ambiguous],
329
+ required_context_events: persistence[:required_context_events]
330
+ }
331
+ definitions[assigned_name] = Array(definitions[assigned_name]) | [definition]
332
+ elsif node.is_a?(Array)
333
+ node.each { |child| collect_assignment_targets(child, namespace, definitions, signature, contexts) }
334
+ end
335
+ end
336
+
337
+ def assignment_operator(node)
338
+ node.first == :opassign ? node.dig(2, 1) : nil
339
+ end
340
+
341
+ def collect_const_set_definition(node, namespace, contexts, definitions)
342
+ receiver, method_name, arguments = call_parts(node)
343
+ return unless method_name == 'const_set'
344
+
345
+ constant_name = literal_constant_name(arguments.first)
346
+ return unless constant_name
347
+
348
+ owner_name = if receiver.nil? || self_receiver?(receiver)
349
+ namespace.join('::')
350
+ elsif object_receiver?(receiver)
351
+ ''
352
+ else
353
+ receiver_constant_name_from_node(receiver, namespace)
354
+ end
355
+ return if owner_name.nil?
356
+
357
+ assigned_name = [owner_name, constant_name].reject(&:empty?).join('::')
358
+ signature = [:const_set, nil, contexts.map(&:first)]
359
+ line = source_line(node)
360
+ persistence = persistence_metadata(contexts, assigned_name, namespace, line, false)
361
+ definition = {
362
+ signature: signature,
363
+ line: line,
364
+ persistence_line: persistence[:line],
365
+ persistence_ambiguous: persistence[:ambiguous],
366
+ required_context_events: persistence[:required_context_events]
367
+ }
368
+ definitions[assigned_name] = Array(definitions[assigned_name]) | [definition]
369
+ end
370
+
371
+ def call_parts(node)
372
+ case node.first
373
+ when :method_add_arg
374
+ call = node[1]
375
+ receiver = call[1] if call&.first == :call
376
+ method_token = call&.first == :call ? call[3] : call&.[](1)
377
+ [receiver, method_token&.[](1), call_arguments(node[2])]
378
+ when :command_call
379
+ [node[1], node.dig(3, 1), call_arguments(node[4])]
380
+ when :command
381
+ [nil, node.dig(1, 1), call_arguments(node[2])]
382
+ end
383
+ end
384
+
385
+ def call_arguments(node)
386
+ return [] unless node.is_a?(Array)
387
+ return call_arguments(node[1]) if node.first == :arg_paren
388
+ return Array(node[1]) if node.first == :args_add_block
389
+
390
+ []
391
+ end
392
+
393
+ def literal_constant_name(node)
394
+ return nil unless node.is_a?(Array)
395
+ return nil unless %i[symbol_literal string_literal].include?(node.first)
396
+
397
+ token = find_syntax_token(node) { |type, _value| %i[@const @ident @tstring_content].include?(type) }
398
+ token&.[](1)
399
+ end
400
+
401
+ def object_receiver?(node)
402
+ node&.first == :var_ref && node.dig(1, 0) == :@const && node.dig(1, 1) == 'Object'
403
+ end
404
+
405
+ def self_receiver?(node)
406
+ node&.first == :var_ref && node.dig(1, 0) == :@kw && node.dig(1, 1) == 'self'
407
+ end
408
+
409
+ def persistence_metadata(contexts, assigned_name, namespace, assignment_line, short_circuit_assignment)
410
+ guard = contexts.reverse.find do |context|
411
+ missing_constant_guard?(context.first, assigned_name, namespace)
412
+ end
413
+ persistence_line = guard&.[](1)
414
+ persistence_line ||= assignment_line if short_circuit_assignment
415
+ return { line: nil, ambiguous: false, required_context_events: [] } unless persistence_line
416
+
417
+ same_line_contexts = contexts.select { |context| context[1] == persistence_line }
418
+ ambiguous = same_line_contexts.any? do |context|
419
+ !lazy_context?(context) &&
420
+ !missing_constant_guard?(context.first, assigned_name, namespace)
421
+ end
422
+ required_context_events = same_line_contexts.filter_map do |context|
423
+ context[2]
424
+ end
425
+ {
426
+ line: persistence_line,
427
+ ambiguous: ambiguous,
428
+ required_context_events: required_context_events
429
+ }
430
+ end
431
+
432
+ def missing_constant_guard?(context, assigned_name, namespace)
433
+ condition, expected = guarded_condition(context)
434
+ return false unless condition
435
+
436
+ condition, expected = unwrap_guard_condition(condition, expected)
437
+ expected == false && guarded_constant_name(condition, namespace) == assigned_name
438
+ end
439
+
440
+ def guarded_condition(context)
441
+ case context.first
442
+ when :if
443
+ [context[2], context[1] == :then]
444
+ when :unless
445
+ [context[2], context[1] != :then]
446
+ when :if_mod
447
+ [context[1], true]
448
+ when :unless_mod
449
+ [context[1], false]
450
+ when :while, :while_mod
451
+ [context[1], true]
452
+ when :until, :until_mod
453
+ [context[1], false]
454
+ when :ifop
455
+ [context[2], context[1] == :then]
456
+ when :binary
457
+ [context[2], context[1] == :'&&']
458
+ end
459
+ end
460
+
461
+ def unwrap_guard_condition(condition, expected)
462
+ loop do
463
+ case condition&.first
464
+ when :paren
465
+ expressions = condition[1]
466
+ break unless expressions.is_a?(Array) && expressions.length == 1
467
+
468
+ condition = expressions.first
469
+ when :unary
470
+ break unless condition[1] == :!
471
+
472
+ condition = condition[2]
473
+ expected = !expected
474
+ when :call
475
+ break unless condition.dig(3, 1) == 'nil?'
476
+
477
+ condition = condition[1]
478
+ expected = !expected
479
+ when :binary
480
+ left, operator, right = condition.drop(1)
481
+ break unless operator == :== && nil_literal?(right)
482
+
483
+ condition = left
484
+ expected = !expected
485
+ else
486
+ break
487
+ end
488
+ end
489
+ [condition, expected]
490
+ end
491
+
492
+ def nil_literal?(node)
493
+ node&.first == :var_ref && node.dig(1, 0) == :@kw && node.dig(1, 1) == 'nil'
494
+ end
495
+
496
+ def guarded_constant_name(condition, namespace)
497
+ return constant_name_from_node(condition[1], namespace) if condition&.first == :defined
498
+
499
+ receiver, method_name, arguments = call_parts(condition)
500
+ return nil unless method_name == 'const_defined?'
501
+
502
+ constant_name = literal_constant_name(arguments.first)
503
+ return nil unless constant_name
504
+
505
+ owner_name = if receiver.nil? || self_receiver?(receiver)
506
+ namespace.join('::')
507
+ elsif object_receiver?(receiver)
508
+ ''
509
+ else
510
+ receiver_constant_name_from_node(receiver, namespace)
511
+ end
512
+ return nil if owner_name.nil?
513
+
514
+ [owner_name, constant_name].reject(&:empty?).join('::')
515
+ end
516
+
517
+ def receiver_constant_name_from_node(node, namespace)
518
+ return nil unless node.is_a?(Array)
519
+
520
+ case node.first
521
+ when :var_ref, :const_ref
522
+ receiver_constant_name_from_node(node[1], namespace)
523
+ when :@const
524
+ resolve_lexical_constant_name(node[1], namespace)
525
+ when :const_path_ref
526
+ parent_name = receiver_constant_name_from_node(node[1], namespace)
527
+ child_name = node.dig(2, 1)
528
+ [parent_name, child_name].compact.join('::')
529
+ when :top_const_ref
530
+ node.dig(1, 1)
531
+ end
532
+ end
533
+
534
+ def resolve_lexical_constant_name(constant_name, namespace)
535
+ namespace.length.downto(0) do |depth|
536
+ candidate = (namespace.first(depth) + [constant_name]).join('::')
537
+ return candidate if constant_path_defined?(candidate)
538
+ end
539
+
540
+ (namespace + [constant_name]).join('::')
541
+ end
542
+
543
+ def constant_path_defined?(name)
544
+ parts = name.split('::')
545
+ parts.each_with_index.reduce(Object) do |owner, (part, index)|
546
+ return false unless owner.is_a?(Module) && owner.const_defined?(part, false)
547
+ return true if index == parts.length - 1
548
+ return false if owner.autoload?(part, false)
549
+
550
+ owner.const_get(part, false)
551
+ end
552
+ true
553
+ rescue StandardError
554
+ false
555
+ end
556
+
557
+ def lazy_context?(context)
558
+ !context[2].nil?
559
+ end
560
+
561
+ def find_syntax_token(node, &predicate)
562
+ return nil unless node.is_a?(Array)
563
+ return node if node.first.to_s.start_with?('@') && predicate.call(node[0], node[1])
564
+
565
+ node.each do |child|
566
+ token = find_syntax_token(child, &predicate)
567
+ return token if token
568
+ end
569
+ nil
570
+ end
571
+
572
+ def canonical_syntax(node)
573
+ return node unless node.is_a?(Array)
574
+ return [node[0], node[1]] if node.first.to_s.start_with?('@')
575
+
576
+ node.map { |child| canonical_syntax(child) }
577
+ end
578
+
579
+ def source_line(node)
580
+ return nil unless node.is_a?(Array)
581
+ return node.dig(2, 0) if node.first.to_s.start_with?('@')
582
+
583
+ node.each do |child|
584
+ line = source_line(child)
585
+ return line if line
586
+ end
587
+ nil
588
+ end
589
+
590
+ def constant_name_from_node(node, namespace)
591
+ return nil unless node.is_a?(Array)
592
+
593
+ case node.first
594
+ when :var_field, :const_ref, :var_ref
595
+ constant_name_from_node(node[1], namespace)
596
+ when :@const
597
+ (namespace + [node[1]]).join('::')
598
+ when :const_path_field, :const_path_ref
599
+ parent_name = receiver_constant_name_from_node(node[1], namespace)
600
+ child_name = node.dig(2, 1)
601
+ [parent_name, child_name].compact.join('::')
602
+ when :top_const_field, :top_const_ref
603
+ node.dig(1, 1)
604
+ end
605
+ end
49
606
  end
50
607
  end