carton-tools 0.3.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,649 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'prism'
4
+
5
+ module Carton
6
+ module Tools
7
+ # What one Ruby file imports, requires, exports, and defines at top level,
8
+ # read with Prism without running it. `carton rbs` and `carton check` build
9
+ # on these facts.
10
+ class SourceFile
11
+ # An `import`, `import_relative`, `require`, or `require_relative` call.
12
+ # `path` is nil when the argument is not a string literal. `lazy` is true
13
+ # inside a method, block, or lambda, which may run after loading.
14
+ # `constant` and `local` name the constant or local variable the call's
15
+ # result is assigned to, and `pattern` is the Reference for a pattern
16
+ # matched directly against it, such as `import('math') => { add: }`.
17
+ Call =
18
+ Data.define(
19
+ :kind,
20
+ :path,
21
+ :file,
22
+ :line,
23
+ :lazy,
24
+ :constant,
25
+ :local,
26
+ :pattern,
27
+ ) { def relative? = kind.end_with?('_relative') }
28
+
29
+ # A use of a name that may be bound to an import: `name` as written (a
30
+ # constant path, or a local variable when `local`), the innermost
31
+ # namespace it appears in, and how it is used. `kind` is :call for a
32
+ # method call (`X.add`), :const for a constant path (`X::PI`), :fetch for
33
+ # a lookup that raises when the name is missing (`fetch`, a `=>`
34
+ # pattern), :lookup for one that does not (`[]`, `key?`, an `in`
35
+ # pattern), and :value when the namespace itself is passed on, so its
36
+ # uses cannot be followed. `member` is the name used, or nil for :value.
37
+ # `rescued` lists the exception classes an enclosing `rescue` catches.
38
+ Reference =
39
+ Data.define(:name, :local, :scope, :kind, :member, :line, :rescued)
40
+
41
+ # A hash pattern matched directly against an import: its Reference
42
+ # `kind` (:fetch for `=>`, :lookup for `in`) and the keys it reads, with
43
+ # `'**'` for a rest pattern.
44
+ Pattern = Data.define(:kind, :keys)
45
+
46
+ # An `export` call at top level. `value` names the constant it exports,
47
+ # if it is one. `keys` maps a keyword or Hash literal export's keys to
48
+ # RBS types, or is nil. `symbol` is true when it was given a Symbol,
49
+ # which `export` refuses. `branches` lists the enclosing `if`,
50
+ # `unless`, and `case` branches as [conditional, branch] pairs, so two
51
+ # calls in different branches of the same conditional are known never
52
+ # to both run.
53
+ Export =
54
+ Data.define(:value, :keys, :symbol, :line, :branches) do
55
+ def exclusive?(other)
56
+ branches.any? do |id, branch|
57
+ other.branches.any? do |other_id, other_branch|
58
+ id == other_id && branch != other_branch
59
+ end
60
+ end
61
+ end
62
+ end
63
+
64
+ # A top-level method and its RBS method type.
65
+ Def = Data.define(:name, :type, :line)
66
+
67
+ IMPORTS = %i[import import_relative].freeze
68
+ REQUIRES = %i[require require_relative].freeze
69
+
70
+ attr_reader :path,
71
+ :imports,
72
+ :requires,
73
+ :exports,
74
+ :methods,
75
+ :constants,
76
+ :definitions,
77
+ :references,
78
+ :private_constants,
79
+ :local_writes,
80
+ :errors
81
+
82
+ def self.parse(path) = new(path, Prism.parse_file(path))
83
+
84
+ def initialize(path, result)
85
+ @path = path
86
+ @imports = []
87
+ @requires = []
88
+ @exports = []
89
+ # Top-level methods by name, and RBS types of top-level constants.
90
+ @methods = {}
91
+ @constants = {}
92
+ # Every class and module defined, by full name: :class or :module.
93
+ @definitions = {}
94
+ @references = []
95
+ # Full names made private with `private_constant`.
96
+ @private_constants = []
97
+ # How many times each local variable is assigned, so a local bound to
98
+ # an import is only followed when nothing reassigns it.
99
+ @local_writes = Hash.new(0)
100
+ @errors =
101
+ result.errors.map do |error|
102
+ [error.location.start_line, error.message]
103
+ end
104
+ @annotations =
105
+ result.comments.to_h do |comment|
106
+ [comment.location.start_line, comment.slice]
107
+ end
108
+ Visitor.new(self).visit(result.value)
109
+ end
110
+
111
+ # Whether a `# carton:dynamic` comment on `line`, or the line above,
112
+ # marks an import as intentionally dynamic.
113
+ def dynamic_marker?(line)
114
+ [line, line - 1].any? do |l|
115
+ @annotations[l]&.include?('carton:dynamic')
116
+ end
117
+ end
118
+
119
+ # The rbs-inline `#: TYPE` annotation on `line`, if any.
120
+ def annotation(line)
121
+ text = @annotations[line]
122
+ text[2..].strip if text&.start_with?('#:')
123
+ end
124
+
125
+ # The RBS type of a literal value node, or `untyped`.
126
+ def self.literal_type(node)
127
+ case node
128
+ when Prism::StringNode, Prism::InterpolatedStringNode
129
+ '::String'
130
+ when Prism::SymbolNode
131
+ '::Symbol'
132
+ when Prism::IntegerNode
133
+ '::Integer'
134
+ when Prism::FloatNode
135
+ '::Float'
136
+ when Prism::TrueNode, Prism::FalseNode
137
+ 'bool'
138
+ when Prism::NilNode
139
+ 'nil'
140
+ when Prism::ArrayNode
141
+ '::Array[untyped]'
142
+ when Prism::HashNode
143
+ '::Hash[untyped, untyped]'
144
+ when Prism::RegularExpressionNode
145
+ '::Regexp'
146
+ when Prism::CallNode
147
+ node.name == :freeze ? literal_type(node.receiver) : 'untyped'
148
+ else
149
+ 'untyped'
150
+ end
151
+ end
152
+
153
+ # An RBS method type with untyped parameters matching a `def`.
154
+ def self.method_type(parameters)
155
+ return '() -> untyped' unless parameters
156
+
157
+ params = parameters.requireds.map { |node| positional(node) }
158
+ parameters.optionals.each { |node| params << "?untyped #{node.name}" }
159
+ if parameters.rest.is_a?(Prism::RestParameterNode)
160
+ params << "*untyped #{parameters.rest.name}".strip
161
+ end
162
+ parameters.posts.each { |node| params << positional(node) }
163
+ parameters.keywords.each do |node|
164
+ optional = node.is_a?(Prism::OptionalKeywordParameterNode)
165
+ params << "#{'?' if optional}#{node.name}: untyped"
166
+ end
167
+ block = parameters.block ? ' ?{ (?) -> untyped }' : ''
168
+ case parameters.keyword_rest
169
+ when Prism::KeywordRestParameterNode
170
+ params << "**untyped #{parameters.keyword_rest.name}".strip
171
+ when Prism::ForwardingParameterNode
172
+ params.push('*untyped', '**untyped')
173
+ block = ' ?{ (?) -> untyped }'
174
+ end
175
+ "(#{params.join(', ')})#{block} -> untyped"
176
+ end
177
+
178
+ def self.positional(node)
179
+ node.respond_to?(:name) ? "untyped #{node.name}" : 'untyped'
180
+ end
181
+ private_class_method :positional
182
+
183
+ # Walks the tree once, tracking the class and module nesting and whether
184
+ # code runs at load time.
185
+ class Visitor < Prism::Visitor
186
+ # Lookups by name, and whether a missing name raises.
187
+ LOOKUPS = {
188
+ :[] => :lookup,
189
+ :key? => :lookup,
190
+ :has_key? => :lookup,
191
+ :values_at => :lookup,
192
+ :fetch => :fetch,
193
+ :fetch_values => :fetch,
194
+ }.freeze
195
+ # Calls that reach names the code does not spell out.
196
+ ESCAPES = %i[
197
+ keys
198
+ to_h
199
+ deconstruct_keys
200
+ send
201
+ public_send
202
+ __send__
203
+ method
204
+ public_method
205
+ const_get
206
+ constants
207
+ respond_to?
208
+ ].freeze
209
+
210
+ def initialize(file)
211
+ super()
212
+ @file = file
213
+ @namespace = []
214
+ @defs = 0
215
+ @blocks = 0
216
+ @constant = nil
217
+ @local = nil
218
+ @pattern = nil
219
+ # Nodes already recorded as a receiver or pattern value, and the
220
+ # constant paths that name a class or module being defined.
221
+ @handled = {}.compare_by_identity
222
+ @defining = {}.compare_by_identity
223
+ @rescued = []
224
+ @branches = []
225
+ end
226
+
227
+ # Code in a `begin` body may raise into its `rescue` clauses, which
228
+ # record the classes they catch; a bare `rescue` catches StandardError.
229
+ def visit_begin_node(node)
230
+ return super unless node.rescue_clause
231
+
232
+ rescuing(rescued_classes(node.rescue_clause)) do
233
+ visit(node.statements) if node.statements
234
+ end
235
+ visit(node.rescue_clause)
236
+ visit(node.else_clause) if node.else_clause
237
+ visit(node.ensure_clause) if node.ensure_clause
238
+ end
239
+
240
+ def visit_rescue_modifier_node(node)
241
+ rescuing(['StandardError']) { visit(node.expression) }
242
+ visit(node.rescue_expression)
243
+ end
244
+
245
+ def visit_call_node(node)
246
+ record_call(node) if node.receiver.nil?
247
+ record_use(node) if bindable?(node.receiver)
248
+ super
249
+ end
250
+
251
+ def visit_constant_path_node(node)
252
+ unless @defining[node]
253
+ if bindable?(node.parent)
254
+ @handled[node.parent] = true
255
+ reference(node.parent, :const, node.name.to_s)
256
+ end
257
+ reference(node, :value, nil) unless @handled[node]
258
+ end
259
+ super
260
+ end
261
+
262
+ def visit_constant_read_node(node)
263
+ reference(node, :value, nil) unless @defining[node] || @handled[node]
264
+ super
265
+ end
266
+
267
+ def visit_local_variable_read_node(node)
268
+ reference(node, :value, nil) unless @handled[node]
269
+ super
270
+ end
271
+
272
+ def visit_local_variable_write_node(node)
273
+ name = node.name.to_s
274
+ @file.local_writes[name] += 1
275
+ binding(:@local, name, node.value) { super }
276
+ end
277
+
278
+ %i[
279
+ local_variable_operator_write_node
280
+ local_variable_or_write_node
281
+ local_variable_and_write_node
282
+ local_variable_target_node
283
+ ].each do |type|
284
+ define_method(:"visit_#{type}") do |node|
285
+ @file.local_writes[node.name.to_s] += 1
286
+ super(node)
287
+ end
288
+ end
289
+
290
+ def visit_match_required_node(node)
291
+ match(node.value, node.pattern, :fetch) { super }
292
+ end
293
+
294
+ def visit_match_predicate_node(node)
295
+ match(node.value, node.pattern, :lookup) { super }
296
+ end
297
+
298
+ def visit_def_node(node)
299
+ if top_level? && node.receiver.nil?
300
+ line = node.location.start_line
301
+ type =
302
+ @file.annotation(line - 1) ||
303
+ SourceFile.method_type(node.parameters)
304
+ @file.methods[node.name.to_s] = Def.new(node.name.to_s, type, line)
305
+ end
306
+ nest(:@defs) { super }
307
+ end
308
+
309
+ def visit_block_node(node) = nest(:@blocks) { super }
310
+
311
+ def visit_lambda_node(node) = nest(:@blocks) { super }
312
+
313
+ def visit_class_node(node) = define(node, :class) { super }
314
+
315
+ def visit_module_node(node) = define(node, :module) { super }
316
+
317
+ def visit_case_match_node(node)
318
+ node.conditions.each do |condition|
319
+ next unless condition.is_a?(Prism::InNode)
320
+
321
+ match(node.predicate, condition.pattern, :lookup)
322
+ end
323
+ visit_branches(
324
+ node,
325
+ node.predicate,
326
+ node.conditions,
327
+ node.else_clause,
328
+ )
329
+ end
330
+
331
+ def visit_case_node(node)
332
+ visit_branches(
333
+ node,
334
+ node.predicate,
335
+ node.conditions,
336
+ node.else_clause,
337
+ )
338
+ end
339
+
340
+ def visit_if_node(node)
341
+ visit_branches(
342
+ node,
343
+ node.predicate,
344
+ [node.statements],
345
+ node.subsequent,
346
+ )
347
+ end
348
+
349
+ def visit_unless_node(node)
350
+ visit_branches(
351
+ node,
352
+ node.predicate,
353
+ [node.statements],
354
+ node.else_clause,
355
+ )
356
+ end
357
+
358
+ def visit_singleton_class_node(node) = nest(:@defs) { super }
359
+
360
+ def visit_constant_write_node(node)
361
+ name = qualify(node.name.to_s)
362
+ if top_level?
363
+ @file.constants[name] = @file.annotation(
364
+ node.location.start_line,
365
+ ) || SourceFile.literal_type(node.value)
366
+ end
367
+ binding(:@constant, name, node.value) { super }
368
+ end
369
+
370
+ def visit_constant_path_write_node(node)
371
+ name = constant_name(node.target)
372
+ @defining[node.target] = true
373
+ binding(:@constant, name && qualify(name), node.value) { super }
374
+ end
375
+
376
+ private
377
+
378
+ def record_call(node)
379
+ if IMPORTS.include?(node.name) || REQUIRES.include?(node.name)
380
+ record_load(node)
381
+ elsif node.name == :export && top_level?
382
+ record_export(node)
383
+ elsif node.name == :private_constant && @namespace.any?
384
+ record_private(@namespace.last, node)
385
+ end
386
+ end
387
+
388
+ def record_load(node)
389
+ args = node.arguments&.arguments || []
390
+ return unless args.size == 1
391
+
392
+ call =
393
+ Call.new(
394
+ kind: node.name.to_s,
395
+ path:
396
+ (args.first.unescaped if args.first.is_a?(Prism::StringNode)),
397
+ file: @file.path,
398
+ line: node.location.start_line,
399
+ lazy: @defs.positive? || @blocks.positive?,
400
+ constant: @constant,
401
+ local: @local,
402
+ pattern: (@pattern.last if @pattern&.first.equal?(node)),
403
+ )
404
+ list = IMPORTS.include?(node.name) ? @file.imports : @file.requires
405
+ list << call
406
+ end
407
+
408
+ def record_export(node)
409
+ args = node.arguments&.arguments || []
410
+ arg = args.first if args.size == 1
411
+ @file.exports << Export.new(
412
+ value: constant_name(arg),
413
+ keys: hash_types(arg),
414
+ symbol: arg.is_a?(Prism::SymbolNode) || arg.is_a?(Prism::DefNode),
415
+ line: node.location.start_line,
416
+ branches: @branches.dup,
417
+ )
418
+ end
419
+
420
+ # `export version: '1.0'` or `export({ version: '1.0' })`: each
421
+ # Symbol key's RBS type, or nil when a key is not a Symbol literal.
422
+ def hash_types(node)
423
+ case node
424
+ when Prism::KeywordHashNode, Prism::HashNode
425
+ node.elements.to_h do |element|
426
+ unless element.is_a?(Prism::AssocNode) &&
427
+ element.key.is_a?(Prism::SymbolNode)
428
+ return nil
429
+ end
430
+
431
+ [element.key.unescaped, SourceFile.literal_type(element.value)]
432
+ end
433
+ end
434
+ end
435
+
436
+ # Visit a conditional's predicate, then each branch and the else
437
+ # branch under its own [conditional, branch] entry.
438
+ def visit_branches(node, predicate, branches, otherwise)
439
+ visit(predicate) if predicate
440
+ id = node.location.start_offset
441
+ [*branches, otherwise].each_with_index do |branch, index|
442
+ next unless branch
443
+
444
+ @branches.push([id, index])
445
+ begin
446
+ visit(branch)
447
+ ensure
448
+ @branches.pop
449
+ end
450
+ end
451
+ end
452
+
453
+ # A method call on a name that may be bound to an import.
454
+ def record_use(node)
455
+ receiver = node.receiver
456
+ @handled[receiver] = true
457
+ if node.name == :private_constant
458
+ record_private(qualify(constant_name(receiver).to_s), node)
459
+ elsif LOOKUPS.key?(node.name)
460
+ record_lookup(receiver, node)
461
+ elsif ESCAPES.include?(node.name)
462
+ reference(receiver, :value, nil)
463
+ else
464
+ reference(receiver, :call, node.name.to_s)
465
+ end
466
+ end
467
+
468
+ # `fetch` with a default or a block does not raise, and only
469
+ # `values_at` and `fetch_values` read every argument.
470
+ def record_lookup(receiver, node)
471
+ args = node.arguments&.arguments || []
472
+ args = args.first(1) unless %i[values_at fetch_values].include?(
473
+ node.name,
474
+ )
475
+ keys = args.map { |arg| literal_key(arg) }
476
+ if keys.empty? || keys.include?(nil)
477
+ return reference(receiver, :value, nil)
478
+ end
479
+
480
+ kind = LOOKUPS[node.name]
481
+ kind = :lookup if node.name == :fetch &&
482
+ (node.block || node.arguments.arguments.size > 1)
483
+ keys.each { |key| reference(receiver, kind, key) }
484
+ end
485
+
486
+ def record_private(namespace, node)
487
+ (node.arguments&.arguments || []).each do |arg|
488
+ next unless arg.is_a?(Prism::SymbolNode)
489
+
490
+ @file.private_constants << [namespace, arg.unescaped].reject(
491
+ &:empty?
492
+ ).join('::')
493
+ end
494
+ end
495
+
496
+ def match(value, pattern, kind)
497
+ keys = pattern_keys(pattern)
498
+ saved = @pattern
499
+ if keys && bindable?(value)
500
+ @handled[value] = true
501
+ keys.each do |key|
502
+ if key == '**'
503
+ reference(value, :value, nil)
504
+ else
505
+ reference(value, kind, key)
506
+ end
507
+ end
508
+ elsif keys && import_call?(value)
509
+ @pattern = [value, Pattern.new(kind:, keys:)]
510
+ end
511
+ yield if block_given?
512
+ ensure
513
+ @pattern = saved
514
+ end
515
+
516
+ # The keys a hash pattern reads, with `'**'` for a rest pattern, or
517
+ # nil for any other pattern.
518
+ def pattern_keys(pattern)
519
+ return unless pattern.is_a?(Prism::HashPatternNode)
520
+
521
+ keys =
522
+ pattern.elements.map do |element|
523
+ key = element.key if element.is_a?(Prism::AssocNode)
524
+ key.is_a?(Prism::SymbolNode) ? key.unescaped : '**'
525
+ end
526
+ keys << '**' if pattern.rest.is_a?(Prism::AssocSplatNode)
527
+ keys
528
+ end
529
+
530
+ def literal_key(node)
531
+ unless node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)
532
+ return
533
+ end
534
+
535
+ node.unescaped
536
+ end
537
+
538
+ def reference(node, kind, member)
539
+ local = node.is_a?(Prism::LocalVariableReadNode)
540
+ name = local ? node.name.to_s : constant_name(node)
541
+ return unless name
542
+
543
+ @file.references << Reference.new(
544
+ name:,
545
+ local:,
546
+ scope: @namespace.last,
547
+ kind:,
548
+ member:,
549
+ line: node.location.start_line,
550
+ rescued: @rescued.flatten.uniq,
551
+ )
552
+ end
553
+
554
+ def rescuing(classes)
555
+ @rescued.push(classes)
556
+ yield
557
+ ensure
558
+ @rescued.pop
559
+ end
560
+
561
+ def rescued_classes(clause)
562
+ classes = []
563
+ while clause
564
+ names = clause.exceptions.filter_map { |node| constant_name(node) }
565
+ classes.concat(names.empty? ? ['StandardError'] : names)
566
+ clause = clause.subsequent
567
+ end
568
+ classes
569
+ end
570
+
571
+ def bindable?(node)
572
+ node.is_a?(Prism::ConstantReadNode) ||
573
+ node.is_a?(Prism::ConstantPathNode) ||
574
+ node.is_a?(Prism::LocalVariableReadNode)
575
+ end
576
+
577
+ def import_call?(node)
578
+ node.is_a?(Prism::CallNode) && node.receiver.nil? &&
579
+ IMPORTS.include?(node.name)
580
+ end
581
+
582
+ def define(node, kind, &)
583
+ mark_defining(node.constant_path)
584
+ name = constant_name(node.constant_path)
585
+ return nest(:@defs, 0, &) unless name
586
+
587
+ name = qualify(name)
588
+ @file.definitions[name] ||= kind
589
+ @namespace.push(name)
590
+ begin
591
+ nest(:@defs, 0, &)
592
+ ensure
593
+ @namespace.pop
594
+ end
595
+ end
596
+
597
+ # A class or module body runs at load time, so it resets `@defs`;
598
+ # `step` 0 keeps the count while restoring it afterwards.
599
+ def nest(counter, step = 1)
600
+ saved = instance_variable_get(counter)
601
+ instance_variable_set(counter, step.zero? ? 0 : saved + step)
602
+ yield
603
+ ensure
604
+ instance_variable_set(counter, saved)
605
+ end
606
+
607
+ # The constant path a class or module definition names is not a use.
608
+ def mark_defining(node)
609
+ while node.is_a?(Prism::ConstantPathNode) ||
610
+ node.is_a?(Prism::ConstantReadNode)
611
+ @defining[node] = true
612
+ node = node.respond_to?(:parent) ? node.parent : nil
613
+ end
614
+ end
615
+
616
+ # Only a direct `CONST = import ...` or `local = import ...` binds the
617
+ # name to the import.
618
+ def binding(ivar, name, value)
619
+ saved = instance_variable_get(ivar)
620
+ instance_variable_set(ivar, (name if import_call?(value)))
621
+ yield
622
+ ensure
623
+ instance_variable_set(ivar, saved)
624
+ end
625
+
626
+ # The constant path as written, or nil for a dynamic one like `x::Y`.
627
+ def constant_name(node)
628
+ case node
629
+ when Prism::ConstantReadNode
630
+ node.name.to_s
631
+ when Prism::ConstantPathNode
632
+ node.full_name
633
+ end
634
+ rescue Prism::ConstantPathNode::DynamicPartsInConstantPathError
635
+ nil
636
+ end
637
+
638
+ def qualify(name)
639
+ return name.delete_prefix('::') if name.start_with?('::')
640
+
641
+ [*@namespace.last, name].join('::')
642
+ end
643
+
644
+ def top_level? = @namespace.empty? && @defs.zero?
645
+ end
646
+ private_constant :Visitor
647
+ end
648
+ end
649
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Carton
4
+ module Tools
5
+ # Always the same as Carton::VERSION: carton-tools reads Carton's
6
+ # internals, so each release depends on exactly its own carton.
7
+ VERSION = '0.3.0'
8
+ end
9
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'tools/version'
4
+ require_relative 'tools/cli'