Skip to content

Commit dd3a727

Browse files
authored
Merge pull request #293 from Shopify/uk-method-generation-improvements
Stop generating more methods than necessary
2 parents b1b3d2b + c367996 commit dd3a727

File tree

117 files changed

+13335
-12683
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

117 files changed

+13335
-12683
lines changed

lib/tapioca/gem/listeners/methods.rb

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,30 @@ def compile_directly_owned_methods(
5656
def compile_method(tree, symbol_name, constant, method, visibility = RBI::Public.new)
5757
return unless method
5858
return unless method_owned_by_constant?(method, constant)
59-
return if @pipeline.symbol_in_payload?(symbol_name) && !@pipeline.method_in_gem?(method)
6059

61-
signature = lookup_signature_of(method)
62-
method = signature.method if signature #: UnboundMethod
60+
begin
61+
signature = signature_of!(method)
62+
method = signature.method if signature #: UnboundMethod
63+
64+
case @pipeline.method_definition_in_gem(method.name, constant)
65+
when Pipeline::MethodUnknown
66+
# This means that this is a C-method. Thus, we want to
67+
# skip it only if the constant is an ignored one, since
68+
# that probably means that we've hit a C-method for a
69+
# core type.
70+
return if @pipeline.symbol_in_payload?(symbol_name)
71+
when Pipeline::MethodNotInGem
72+
# Do not process this method, if it is not defined by the current gem
73+
return
74+
end
75+
rescue SignatureBlockError => error
76+
@pipeline.error_handler.call(<<~MSG)
77+
Unable to compile signature for method: #{method.owner}##{method.name}
78+
Exception raised when loading signature: #{error.cause.inspect}
79+
MSG
80+
81+
signature = nil
82+
end
6383

6484
method_name = method.name.to_s
6585
return unless valid_method_name?(method_name)
@@ -192,18 +212,6 @@ def initialize_method_for(constant)
192212
def ignore?(event)
193213
event.is_a?(Tapioca::Gem::ForeignScopeNodeAdded)
194214
end
195-
196-
#: (UnboundMethod method) -> untyped
197-
def lookup_signature_of(method)
198-
signature_of!(method)
199-
rescue LoadError, StandardError => error
200-
@pipeline.error_handler.call(<<~MSG)
201-
Unable to compile signature for method: #{method.owner}##{method.name}
202-
Exception raised when loading signature: #{error.inspect}
203-
MSG
204-
205-
nil
206-
end
207215
end
208216
end
209217
end

lib/tapioca/gem/listeners/source_location.rb

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,20 +24,24 @@ def on_scope(event)
2424
# constants that are defined by multiple gems.
2525
locations = Runtime::Trackers::ConstantDefinition.locations_for(event.constant)
2626
location = locations.find do |loc|
27-
Pathname.new(loc.path).realpath.to_s.include?(@pipeline.gem.full_gem_path)
27+
Pathname.new(loc.file).realpath.to_s.include?(@pipeline.gem.full_gem_path)
2828
end
2929

3030
# The location may still be nil in some situations, like constant aliases (e.g.: MyAlias = OtherConst). These
3131
# are quite difficult to attribute a correct location, given that the source location points to the original
3232
# constants and not the alias
33-
add_source_location_comment(event.node, location.path, location.lineno) unless location.nil?
33+
add_source_location_comment(event.node, location.file, location.line) unless location.nil?
3434
end
3535

3636
# @override
3737
#: (MethodNodeAdded event) -> void
3838
def on_method(event)
39-
file, line = event.method.source_location
40-
add_source_location_comment(event.node, file, line)
39+
definition = @pipeline.method_definition_in_gem(event.method.name, event.constant)
40+
41+
if Pipeline::MethodInGemWithLocation === definition
42+
loc = definition.location
43+
add_source_location_comment(event.node, loc.file, loc.line)
44+
end
4145
end
4246

4347
#: (RBI::NodeWithComments node, String? file, Integer? line) -> void

lib/tapioca/gem/pipeline.rb

Lines changed: 52 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -107,35 +107,66 @@ def symbol_in_payload?(symbol_name)
107107
@payload_symbols.include?(symbol_name)
108108
end
109109

110-
# this looks something like:
111-
# "(eval at /path/to/file.rb:123)"
112-
# and we are just interested in the "/path/to/file.rb" part
113-
EVAL_SOURCE_FILE_PATTERN = /\(eval at (.+):\d+\)/ #: Regexp
114-
115110
#: ((String | Symbol) name) -> bool
116111
def constant_in_gem?(name)
117-
return true unless Object.respond_to?(:const_source_location)
112+
loc = const_source_location(name)
118113

119-
source_file, _ = Object.const_source_location(name)
120-
return true unless source_file
121-
# If the source location of the constant is "(eval)", all bets are off.
122-
return true if source_file == "(eval)"
114+
# If the source location of the constant isn't available or is "(eval)", all bets are off.
115+
return true if loc.nil? || loc.file.nil? || loc.file == "(eval)"
123116

124-
# Ruby 3.3 adds automatic definition of source location for evals if
125-
# `file` and `line` arguments are not provided. This results in the source
126-
# file being something like `(eval at /path/to/file.rb:123)`. We try to parse
127-
# this string to get the actual source file.
128-
source_file = source_file.sub(EVAL_SOURCE_FILE_PATTERN, "\\1")
117+
gem.contains_path?(loc.file)
118+
end
129119

130-
gem.contains_path?(source_file)
120+
class MethodDefinitionLookupResult
121+
extend T::Helpers
122+
abstract!
131123
end
132124

133-
#: (UnboundMethod method) -> bool
134-
def method_in_gem?(method)
135-
source_location = method.source_location&.first
136-
return false if source_location.nil?
125+
# The method doesn't seem to exist
126+
class MethodUnknown < MethodDefinitionLookupResult; end
127+
128+
# The method is not defined in the gem
129+
class MethodNotInGem < MethodDefinitionLookupResult; end
130+
131+
# The method probably defined in the gem but doesn't have a source location
132+
class MethodInGemWithoutLocation < MethodDefinitionLookupResult; end
133+
134+
# The method defined in gem and has a source location
135+
class MethodInGemWithLocation < MethodDefinitionLookupResult
136+
extend T::Sig
137+
138+
#: Runtime::SourceLocation
139+
attr_reader :location
140+
141+
#: (Runtime::SourceLocation location) -> void
142+
def initialize(location)
143+
@location = location
144+
super()
145+
end
146+
end
147+
148+
#: (Symbol method_name, Module owner) -> MethodDefinitionLookupResult
149+
def method_definition_in_gem(method_name, owner)
150+
definitions = Tapioca::Runtime::Trackers::MethodDefinition.method_definitions_for(method_name, owner)
151+
152+
# If the source location of the method isn't available, signal that by returning nil.
153+
return MethodUnknown.new if definitions.empty?
154+
155+
# Look up the first entry that matches a file in the gem.
156+
found = definitions.find { |loc| @gem.contains_path?(loc.file) }
157+
158+
unless found
159+
# If the source location of the method is "(eval)", err on the side of caution and include the method.
160+
found = definitions.find { |loc| loc.file == "(eval)" }
161+
# However, we can just return true to signal that the method should be included.
162+
# We can't provide a source location for it, but we want it to be included in the gem RBI.
163+
return MethodInGemWithoutLocation.new if found
164+
end
165+
166+
# If we searched but couldn't find a source location in the gem, return false to signal that.
167+
return MethodNotInGem.new unless found
137168

138-
@gem.contains_path?(source_location)
169+
MethodInGemWithLocation.new(found)
139170
end
140171

141172
# Helpers

lib/tapioca/runtime/reflection.rb

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# typed: strict
22
# frozen_string_literal: true
33

4+
require "tapioca/runtime/source_location"
5+
46
# On Ruby 3.2 or newer, Class defines an attached_object method that returns the
57
# attached class of a singleton class without iterating ObjectSpace. On older
68
# versions of Ruby, we fall back to iterating ObjectSpace.
@@ -125,15 +127,19 @@ def qualified_name_of(constant)
125127
end
126128
end
127129

130+
SignatureBlockError = Class.new(Tapioca::Error)
131+
128132
#: ((UnboundMethod | Method) method) -> untyped
129133
def signature_of!(method)
130134
T::Utils.signature_for_method(method)
135+
rescue LoadError, StandardError
136+
Kernel.raise SignatureBlockError
131137
end
132138

133139
#: ((UnboundMethod | Method) method) -> untyped
134140
def signature_of(method)
135141
signature_of!(method)
136-
rescue LoadError, StandardError
142+
rescue SignatureBlockError
137143
nil
138144
end
139145

@@ -169,23 +175,46 @@ def descendants_of(klass)
169175
T.unsafe(result)
170176
end
171177

178+
#: ((String | Symbol) constant_name) -> SourceLocation?
179+
def const_source_location(constant_name)
180+
return unless Object.respond_to?(:const_source_location)
181+
182+
file, line = Object.const_source_location(constant_name)
183+
184+
SourceLocation.from_loc([file, line]) if file && line
185+
end
186+
172187
# Examines the call stack to identify the closest location where a "require" is performed
173188
# by searching for the label "<top (required)>" or "block in <class:...>" in the
174189
# case of an ActiveSupport.on_load hook. If none is found, it returns the location
175190
# labeled "<main>", which is the original call site.
176-
#: (Array[Thread::Backtrace::Location]? locations) -> String
191+
#: (Array[Thread::Backtrace::Location]? locations) -> SourceLocation?
177192
def resolve_loc(locations)
178-
return "" unless locations
193+
return unless locations
179194

195+
# Find the location of the closest file load, which should give us the location of the file that
196+
# triggered the definition.
180197
resolved_loc = locations.find do |loc|
181198
label = loc.label
182199
next unless label
183200

184201
REQUIRED_FROM_LABELS.include?(label) || label.start_with?("block in <class:")
185202
end
186-
return "" unless resolved_loc
203+
return unless resolved_loc
204+
205+
resolved_loc_path = resolved_loc.absolute_path || resolved_loc.path
206+
207+
# Find the location of the last frame in this file to get the most accurate line number.
208+
resolved_loc = locations.find { |loc| loc.absolute_path == resolved_loc_path }
209+
return unless resolved_loc
210+
211+
# If the last operation was a `require`, and we have no more frames,
212+
# we are probably dealing with a C-method.
213+
return if locations.first&.label == "require"
214+
215+
file = resolved_loc.absolute_path || resolved_loc.path || ""
187216

188-
resolved_loc.absolute_path || ""
217+
SourceLocation.from_loc([file, resolved_loc.lineno])
189218
end
190219

191220
#: (Module constant) -> Set[String]
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# typed: true
2+
# frozen_string_literal: true
3+
4+
module Tapioca
5+
module Runtime
6+
class SourceLocation
7+
# this looks something like:
8+
# "(eval at /path/to/file.rb:123)"
9+
# and we are interested in the "/path/to/file.rb" and "123" parts
10+
EVAL_SOURCE_FILE_PATTERN = /^\(eval at (?<file>.+):(?<line>\d+)\)/ #: Regexp
11+
12+
#: String
13+
attr_reader :file
14+
15+
#: Integer
16+
attr_reader :line
17+
18+
def initialize(file:, line:)
19+
# Ruby 3.3 adds automatic definition of source location for evals if
20+
# `file` and `line` arguments are not provided. This results in the source
21+
# file being something like `(eval at /path/to/file.rb:123)`. We try to parse
22+
# this string to get the actual source file.
23+
eval_pattern_match = EVAL_SOURCE_FILE_PATTERN.match(file)
24+
if eval_pattern_match
25+
file = eval_pattern_match[:file]
26+
line = eval_pattern_match[:line].to_i
27+
end
28+
29+
@file = file
30+
@line = line
31+
end
32+
33+
# force all callers to use the from_loc method
34+
private_class_method :new
35+
36+
class << self
37+
#: ([String?, Integer?]? loc) -> SourceLocation?
38+
def from_loc(loc)
39+
new(file: loc.first, line: loc.last) if loc&.first && loc.last
40+
end
41+
end
42+
end
43+
end
44+
end

lib/tapioca/runtime/trackers.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,4 @@ def register_tracker(tracker)
5252
require "tapioca/runtime/trackers/constant_definition"
5353
require "tapioca/runtime/trackers/autoload"
5454
require "tapioca/runtime/trackers/required_ancestor"
55+
require "tapioca/runtime/trackers/method_definition"

lib/tapioca/runtime/trackers/constant_definition.rb

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,7 @@ module ConstantDefinition
1313
extend Reflection
1414
extend T::Sig
1515

16-
class ConstantLocation < T::Struct
17-
const :lineno, Integer
18-
const :path, String
19-
end
20-
21-
@class_files = {}.compare_by_identity
16+
@class_files = {}.compare_by_identity #: Hash[Module, Set[SourceLocation]]
2217

2318
# Immediately activated upon load. Observes class/module definition.
2419
@class_tracepoint = TracePoint.trace(:class) do |tp|
@@ -28,14 +23,17 @@ class ConstantLocation < T::Struct
2823

2924
path = tp.path
3025
if File.exist?(path)
31-
loc = build_constant_location(tp, caller_locations)
26+
loc = build_source_location(tp, caller_locations)
3227
else
3328
caller_location = T.must(caller_locations)
3429
.find { |loc| loc.path && File.exist?(loc.path) }
3530

3631
next unless caller_location
3732

38-
loc = ConstantLocation.new(path: caller_location.absolute_path || "", lineno: caller_location.lineno)
33+
loc = SourceLocation.from_loc([
34+
caller_location.absolute_path || "",
35+
caller_location.lineno,
36+
])
3937
end
4038

4139
(@class_files[key] ||= Set.new) << loc
@@ -47,31 +45,37 @@ class ConstantLocation < T::Struct
4745
key = tp.return_value
4846
next unless Module === key
4947

50-
loc = build_constant_location(tp, caller_locations)
48+
loc = build_source_location(tp, caller_locations)
5149
(@class_files[key] ||= Set.new) << loc
5250
end
5351

5452
class << self
53+
extend T::Sig
54+
5555
def disable!
5656
@class_tracepoint.disable
5757
@creturn_tracepoint.disable
5858
super
5959
end
6060

61-
def build_constant_location(tp, locations)
62-
file = resolve_loc(locations)
63-
lineno = File.identical?(file, tp.path) ? tp.lineno : 0
61+
def build_source_location(tp, locations)
62+
loc = resolve_loc(locations)
63+
file = loc&.file
64+
line = loc&.line
65+
lineno = file && File.identical?(file, tp.path) ? tp.lineno : (line || 0)
6466

65-
ConstantLocation.new(path: file, lineno: lineno)
67+
SourceLocation.from_loc([file || "", lineno])
6668
end
6769

6870
# Returns the files in which this class or module was opened. Doesn't know
6971
# about situations where the class was opened prior to +require+ing,
7072
# or where metaprogramming was used via +eval+, etc.
73+
#: (Module klass) -> Set[String]
7174
def files_for(klass)
72-
locations_for(klass).map(&:path).to_set
75+
locations_for(klass).map(&:file).to_set
7376
end
7477

78+
#: (Module klass) -> Set[SourceLocation]
7579
def locations_for(klass)
7680
@class_files.fetch(klass, Set.new)
7781
end

0 commit comments

Comments
 (0)