tapioca 0.17.1 → 0.17.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a8f3834c7af239f3ba9c26395de29e0abb2b6345d10cc2c18418d9ac3eebb7a4
4
- data.tar.gz: ca86a67b0d22cfc65a25e185cc93184e9ee9c94fd8dbb082973978feaf566cb4
3
+ metadata.gz: 01200af75555809c4d8c6d4fd32cc61bebdfc11e6f4aec27a99f8d13bd51fa66
4
+ data.tar.gz: b329558753d7784f88bb7afe1f0030c9424d9907c4b0fcc53b669b8caab6ec2a
5
5
  SHA512:
6
- metadata.gz: 19cd7a29877ae6abbd68d19d6375b3654339fd8b3f5605d0b242db29713401a403cf16c516dfee65e6cbf65b0141d96b3514337d28b0fd612e204524e9a7f3f4
7
- data.tar.gz: ab450b1fa72023fdd7daab0df32aa3e435637cceb608545d67c0b98ed9e114b1c56232fac9ba51305c7ddbe300910370d7085cf8bfebf286cd031fcb444bafcb
6
+ metadata.gz: 77841967e1d02af13ed5a1f2215818f4b63b605c359c26b437f0b6129be1d70dc554666a7aea8710acae812bec4ef422ffc0cd7ae797df87eecb2287f6591352
7
+ data.tar.gz: 7ad4ccd1a11c2a2abcdaa3b1597dd87b124b5da1d800b61736dbe663d1d5c32219de6f491941139441e790834700e64ac6ded2cedcf0d49e91badfd66d9fe76b
data/README.md CHANGED
@@ -52,6 +52,9 @@ Tapioca makes it easy to work with [Sorbet](https://sorbet.org) in your codebase
52
52
  * [Writing custom DSL extensions](#writing-custom-dsl-extensions)
53
53
  * [RBI files for missing constants and methods](#rbi-files-for-missing-constants-and-methods)
54
54
  * [Configuration](#configuration)
55
+ * [Editor Integration](#editor-integration)
56
+ * [Setup](#setup)
57
+ * [Features](#features)
55
58
  * [Contributing](#contributing)
56
59
  * [DSL compilers](#dsl-compilers)
57
60
  * [License](#license)
@@ -395,6 +398,12 @@ Tapioca also supports pulling annotations from multiple sources:
395
398
  $ bin/tapioca annotations --sources https://raw.githubusercontent.com/$USER/$REPO1/$BRANCH https://raw.githubusercontent.com/$USER/$REPO2/$BRANCH
396
399
  ```
397
400
 
401
+ You can also specify a local directory path to pull annotations from
402
+
403
+ ```shell
404
+ $ bin/tapioca annotations --sources path/to/folder
405
+ ```
406
+
398
407
  #### Basic authentication
399
408
 
400
409
  Private repositories can be used as sources by passing the option `--auth` with an authentication string. For Github, this string is `token $TOKEN` where `$TOKEN` is a [personal access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token):
@@ -993,6 +1002,18 @@ annotations:
993
1002
  ```
994
1003
  <!-- END_CONFIG_TEMPLATE -->
995
1004
 
1005
+ ## Editor Integration
1006
+
1007
+ ### Setup
1008
+
1009
+ Tapioca supports generating RBIs upon file changes through the [Ruby LSP's add-on system](https://shopify.github.io/ruby-lsp/#add-ons). It's enabled by default on VS Code using [Ruby LSP](https://shopify.github.io/ruby-lsp/#usage), and you can enable it for other editors by supplying the `enabledFeatureFlags` option in LSP configuration with a hash value of `"tapiocaAddon": true`.
1010
+
1011
+ If you'd like to disable the Tapioca add-on you can set `tapiocaAddon` to `false` in your LSP configuration. For VS Code this looks like `"rubyLsp.featureFlags": { "tapiocaAddon": false }` in your `settings.json`.
1012
+
1013
+ ### Features
1014
+ - DSL RBI generation: When editing a Ruby file, Tapioca will execute `bin/tapioca dsl` with the constants found in your file, e.g. `bin/tapioca dsl MyClass`
1015
+ - Gem RBI generation: When changes are made to `Gemfile.lock`, Tapioca will execute `bin/tapioca gem` with the updated gem names, e.g. `bin/tapioca gem my_gem`
1016
+
996
1017
  ## Contributing
997
1018
 
998
1019
  Bug reports and pull requests are welcome on GitHub at https://github.com/Shopify/tapioca. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](https://github.com/Shopify/tapioca/blob/main/CODE_OF_CONDUCT.md) code of conduct.
@@ -18,8 +18,6 @@ require "ruby_lsp/tapioca/run_gem_rbi_check"
18
18
  module RubyLsp
19
19
  module Tapioca
20
20
  class Addon < ::RubyLsp::Addon
21
- extend T::Sig
22
-
23
21
  #: -> void
24
22
  def initialize
25
23
  super
@@ -44,7 +42,7 @@ module RubyLsp
44
42
  # Get a handle to the Rails add-on's runtime client. The call to `rails_runner_client` will block this thread
45
43
  # until the server has finished booting, but it will not block the main LSP. This has to happen inside of a
46
44
  # thread
47
- addon = T.cast(::RubyLsp::Addon.get("Ruby LSP Rails", ">= 0.4.0", "< 0.5"), ::RubyLsp::Rails::Addon)
45
+ addon = ::RubyLsp::Addon.get("Ruby LSP Rails", ">= 0.4.0", "< 0.5") #: as ::RubyLsp::Rails::Addon
48
46
  @rails_runner_client = addon.rails_runner_client
49
47
  @outgoing_queue << Notification.window_log_message("Activating Tapioca add-on v#{version}")
50
48
  @rails_runner_client.register_server_addon(File.expand_path("server_addon.rb", __dir__))
@@ -93,10 +91,11 @@ module RubyLsp
93
91
  has_route_change = false #: bool
94
92
  has_fixtures_change = false #: bool
95
93
  needs_compiler_reload = false #: bool
94
+ index = @index #: as !nil
96
95
 
97
96
  constants = changes.flat_map do |change|
98
- path = URI(change[:uri]).to_standardized_path
99
- next unless file_updated?(change, path)
97
+ path = URI(change[:uri]).to_standardized_path #: String?
98
+ next unless path && file_updated?(change, path)
100
99
 
101
100
  if File.fnmatch("**/fixtures/**/*.yml{,.erb}", path, File::FNM_PATHNAME | File::FNM_EXTGLOB)
102
101
  has_fixtures_change = true
@@ -115,7 +114,7 @@ module RubyLsp
115
114
  next
116
115
  end
117
116
 
118
- entries = T.must(@index).entries_for(change[:uri])
117
+ entries = index.entries_for(change[:uri])
119
118
  next unless entries
120
119
 
121
120
  entries.filter_map do |entry|
@@ -178,6 +177,8 @@ module RubyLsp
178
177
 
179
178
  #: (Hash[Symbol, untyped] change, String path) -> bool
180
179
  def file_updated?(change, path)
180
+ queue = @outgoing_queue #: as !nil
181
+
181
182
  case change[:type]
182
183
  when Constant::FileChangeType::CREATED
183
184
  @file_checksums[path] = Zlib.crc32(File.read(path)).to_s
@@ -185,7 +186,7 @@ module RubyLsp
185
186
  when Constant::FileChangeType::CHANGED
186
187
  current_checksum = Zlib.crc32(File.read(path)).to_s
187
188
  if @file_checksums[path] == current_checksum
188
- T.must(@outgoing_queue) << Notification.window_log_message(
189
+ queue << Notification.window_log_message(
189
190
  "File has not changed. Skipping #{path}",
190
191
  type: Constant::MessageType::INFO,
191
192
  )
@@ -196,7 +197,7 @@ module RubyLsp
196
197
  when Constant::FileChangeType::DELETED
197
198
  @file_checksums.delete(path)
198
199
  else
199
- T.must(@outgoing_queue) << Notification.window_log_message(
200
+ queue << Notification.window_log_message(
200
201
  "Unexpected file change type: #{change[:type]}",
201
202
  type: Constant::MessageType::WARNING,
202
203
  )
@@ -207,16 +208,16 @@ module RubyLsp
207
208
 
208
209
  #: -> void
209
210
  def run_gem_rbi_check
210
- gem_rbi_check = RunGemRbiCheck.new(T.must(@global_state).workspace_path)
211
+ state = @global_state #: as !nil
212
+ gem_rbi_check = RunGemRbiCheck.new(state.workspace_path)
211
213
  gem_rbi_check.run
212
214
 
213
- T.must(@outgoing_queue) << Notification.window_log_message(
214
- gem_rbi_check.stdout,
215
- ) unless gem_rbi_check.stdout.empty?
216
- T.must(@outgoing_queue) << Notification.window_log_message(
217
- gem_rbi_check.stderr,
218
- type: Constant::MessageType::WARNING,
219
- ) unless gem_rbi_check.stderr.empty?
215
+ queue = @outgoing_queue #: as !nil
216
+ queue << Notification.window_log_message(gem_rbi_check.stdout) unless gem_rbi_check.stdout.empty?
217
+
218
+ unless gem_rbi_check.stderr.empty?
219
+ queue << Notification.window_log_message(gem_rbi_check.stderr, type: Constant::MessageType::WARNING)
220
+ end
220
221
  end
221
222
  end
222
223
  end
@@ -1,4 +1,4 @@
1
- # typed: true
1
+ # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "open3"
@@ -7,10 +7,10 @@ require "ruby_lsp/tapioca/lockfile_diff_parser"
7
7
  module RubyLsp
8
8
  module Tapioca
9
9
  class RunGemRbiCheck
10
- extend T::Sig
10
+ #: String
11
+ attr_reader :stdout, :stderr
11
12
 
12
- attr_reader :stdout
13
- attr_reader :stderr
13
+ #: Process::Status?
14
14
  attr_reader :status
15
15
 
16
16
  #: (String project_path) -> void
@@ -34,11 +34,9 @@ module RubyLsp
34
34
 
35
35
  private
36
36
 
37
- attr_reader :project_path
38
-
39
37
  #: -> bool?
40
38
  def git_repo?
41
- _, status = Open3.capture2e("git", "rev-parse", "--is-inside-work-tree", chdir: project_path)
39
+ _, status = Open3.capture2e("git", "rev-parse", "--is-inside-work-tree", chdir: @project_path)
42
40
  status.success?
43
41
  end
44
42
 
@@ -49,7 +47,7 @@ module RubyLsp
49
47
 
50
48
  #: -> Pathname
51
49
  def lockfile
52
- @lockfile ||= Pathname(project_path).join("Gemfile.lock") #: Pathname?
50
+ @lockfile ||= Pathname(@project_path).join("Gemfile.lock") #: Pathname?
53
51
  end
54
52
 
55
53
  #: -> String
@@ -81,15 +79,16 @@ module RubyLsp
81
79
  #: (Array[String] gems) -> void
82
80
  def execute_tapioca_gem_command(gems)
83
81
  Bundler.with_unbundled_env do
84
- stdout, stderr, status = T.unsafe(Open3).capture3(
85
- "bundle",
86
- "exec",
87
- "tapioca",
88
- "gem",
89
- "--lsp_addon",
90
- *gems,
91
- chdir: project_path,
92
- )
82
+ stdout, stderr, status = Open3 #: as untyped
83
+ .capture3(
84
+ "bundle",
85
+ "exec",
86
+ "tapioca",
87
+ "gem",
88
+ "--lsp_addon",
89
+ *gems,
90
+ chdir: @project_path,
91
+ )
93
92
 
94
93
  log_message(stdout) unless stdout.empty?
95
94
  @stderr = stderr unless stderr.empty?
@@ -101,7 +100,7 @@ module RubyLsp
101
100
  def remove_rbis(gems)
102
101
  files = Dir.glob(
103
102
  "sorbet/rbi/gems/{#{gems.join(",")}}@*.rbi",
104
- base: project_path,
103
+ base: @project_path,
105
104
  )
106
105
  delete_files(files, "Removed RBIs for")
107
106
  end
@@ -117,16 +116,15 @@ module RubyLsp
117
116
 
118
117
  #: (*untyped flags) -> Array[String]
119
118
  def git_ls_gem_rbis(*flags)
120
- flags = T.unsafe(["git", "ls-files", *flags, "sorbet/rbi/gems/"])
121
-
122
- execute_in_project_path(*flags)
119
+ self #: as untyped # rubocop:disable Style/RedundantSelf
120
+ .execute_in_project_path("git", "ls-files", *flags, "sorbet/rbi/gems/")
123
121
  .lines
124
122
  .map(&:strip)
125
123
  end
126
124
 
127
125
  #: (Array[String] files, String message) -> void
128
126
  def delete_files(files, message)
129
- files_to_remove = files.map { |file| File.join(project_path, file) }
127
+ files_to_remove = files.map { |file| File.join(@project_path, file) }
130
128
  FileUtils.rm(files_to_remove)
131
129
  log_message("#{message}: #{files.join(", ")}") unless files.empty?
132
130
  end
@@ -142,10 +140,12 @@ module RubyLsp
142
140
  @stdout += "#{message}\n"
143
141
  end
144
142
 
143
+ #: (*String, ?stdin: String?) -> String
145
144
  def execute_in_project_path(*parts, stdin: nil)
146
- options = { chdir: project_path }
145
+ options = { chdir: @project_path }
147
146
  options[:stdin_data] = stdin if stdin
148
- stdout_and_stderr, _status = T.unsafe(Open3).capture2e(*parts, options)
147
+ stdout_and_stderr, _status = Open3 #: as untyped
148
+ .capture2e(*parts, options)
149
149
  stdout_and_stderr
150
150
  end
151
151
  end
@@ -56,10 +56,30 @@ module Tapioca
56
56
  def compile_method(tree, symbol_name, constant, method, visibility = RBI::Public.new)
57
57
  return unless method
58
58
  return unless method_owned_by_constant?(method, constant)
59
- return if @pipeline.symbol_in_payload?(symbol_name) && !@pipeline.method_in_gem?(method)
60
59
 
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
63
83
 
64
84
  method_name = method.name.to_s
65
85
  return unless valid_method_name?(method_name)
@@ -192,18 +212,6 @@ module Tapioca
192
212
  def ignore?(event)
193
213
  event.is_a?(Tapioca::Gem::ForeignScopeNodeAdded)
194
214
  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
207
215
  end
208
216
  end
209
217
  end
@@ -24,20 +24,24 @@ module Tapioca
24
24
  # constants that are defined by multiple gems.
25
25
  locations = Runtime::Trackers::ConstantDefinition.locations_for(event.constant)
26
26
  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)
28
28
  end
29
29
 
30
30
  # The location may still be nil in some situations, like constant aliases (e.g.: MyAlias = OtherConst). These
31
31
  # are quite difficult to attribute a correct location, given that the source location points to the original
32
32
  # 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?
34
34
  end
35
35
 
36
36
  # @override
37
37
  #: (MethodNodeAdded event) -> void
38
38
  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
41
45
  end
42
46
 
43
47
  #: (RBI::NodeWithComments node, String? file, Integer? line) -> void
@@ -16,6 +16,7 @@ module Tapioca
16
16
  "warn_indent:",
17
17
  "shareable_constant_value:",
18
18
  "rubocop:",
19
+ "@requires_ancestor:",
19
20
  ] #: Array[String]
20
21
 
21
22
  IGNORED_SIG_TAGS = ["param", "return"] #: Array[String]
@@ -29,6 +30,11 @@ module Tapioca
29
30
 
30
31
  private
31
32
 
33
+ #: (String line) -> bool
34
+ def rbs_comment?(line)
35
+ line.strip.start_with?(": ", "| ")
36
+ end
37
+
32
38
  # @override
33
39
  #: (ConstNodeAdded event) -> void
34
40
  def on_const(event)
@@ -60,7 +66,7 @@ module Tapioca
60
66
  return [] if /(copyright|license)/i.match?(docstring)
61
67
 
62
68
  comments = docstring.lines
63
- .reject { |line| IGNORED_COMMENTS.any? { |comment| line.include?(comment) } }
69
+ .reject { |line| IGNORED_COMMENTS.any? { |comment| line.include?(comment) } || rbs_comment?(line) }
64
70
  .map! { |line| RBI::Comment.new(line) }
65
71
 
66
72
  tags = yard_docs.tags
@@ -107,35 +107,66 @@ module Tapioca
107
107
  @payload_symbols.include?(symbol_name)
108
108
  end
109
109
 
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
-
115
110
  #: ((String | Symbol) name) -> bool
116
111
  def constant_in_gem?(name)
117
- return true unless Object.respond_to?(:const_source_location)
112
+ loc = const_source_location(name)
118
113
 
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)"
123
116
 
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
129
119
 
130
- gem.contains_path?(source_file)
120
+ class MethodDefinitionLookupResult
121
+ extend T::Helpers
122
+ abstract!
131
123
  end
132
124
 
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
137
168
 
138
- @gem.contains_path?(source_location)
169
+ MethodInGemWithLocation.new(found)
139
170
  end
140
171
 
141
172
  # Helpers
@@ -16,6 +16,12 @@ require "tapioca/runtime/generic_type_registry"
16
16
 
17
17
  # The rewriter needs to be loaded very early so RBS comments within Tapioca itself are rewritten
18
18
  require "spoom"
19
+ # Eager load all the autoloads at this point, so that we don't enter into
20
+ # a weird loop when the autoloads get triggered and we try to require the file.
21
+ # This is especially important since Prism has a few autoloaded constants that
22
+ # should NOT be rewritten (since they are needed for the rewriting itself), so
23
+ # should be loaded as early as possible.
24
+ Tapioca::Runtime::Trackers::Autoload.eager_load_all!
19
25
  require "tapioca/rbs/rewriter"
20
26
  # ^ Do not change the order of these requires
21
27
 
@@ -26,7 +26,7 @@ begin
26
26
  module InstructionSequenceMixin
27
27
  #: (String) -> RubyVM::InstructionSequence
28
28
  def load_iseq(path)
29
- super
29
+ super if defined?(super)
30
30
  end
31
31
  end
32
32
  end
@@ -49,7 +49,7 @@ RequireHooks.source_transform(patterns: ["**/*.rb"]) do |path, source|
49
49
  if source =~ /^\s*#\s*typed: (ignore|false|true|strict|strong|__STDLIB_INTERNAL)/
50
50
  Spoom::Sorbet::Translate.rbs_comments_to_sorbet_sigs(source, file: path)
51
51
  end
52
- rescue RBI::RBS::MethodTypeTranslator::Error
52
+ rescue Spoom::Sorbet::Translate::Error
53
53
  # If we can't translate the RBS comments back into Sorbet's signatures, we just skip the file.
54
54
  source
55
55
  end
@@ -1,6 +1,8 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
+ require "tapioca/runtime/source_location"
5
+
4
6
  # On Ruby 3.2 or newer, Class defines an attached_object method that returns the
5
7
  # attached class of a singleton class without iterating ObjectSpace. On older
6
8
  # versions of Ruby, we fall back to iterating ObjectSpace.
@@ -125,15 +127,19 @@ module Tapioca
125
127
  end
126
128
  end
127
129
 
130
+ SignatureBlockError = Class.new(Tapioca::Error)
131
+
128
132
  #: ((UnboundMethod | Method) method) -> untyped
129
133
  def signature_of!(method)
130
134
  T::Utils.signature_for_method(method)
135
+ rescue LoadError, StandardError
136
+ Kernel.raise SignatureBlockError
131
137
  end
132
138
 
133
139
  #: ((UnboundMethod | Method) method) -> untyped
134
140
  def signature_of(method)
135
141
  signature_of!(method)
136
- rescue LoadError, StandardError
142
+ rescue SignatureBlockError
137
143
  nil
138
144
  end
139
145
 
@@ -169,23 +175,46 @@ module Tapioca
169
175
  T.unsafe(result)
170
176
  end
171
177
 
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
+
172
187
  # Examines the call stack to identify the closest location where a "require" is performed
173
188
  # by searching for the label "<top (required)>" or "block in <class:...>" in the
174
189
  # case of an ActiveSupport.on_load hook. If none is found, it returns the location
175
190
  # labeled "<main>", which is the original call site.
176
- #: (Array[Thread::Backtrace::Location]? locations) -> String
191
+ #: (Array[Thread::Backtrace::Location]? locations) -> SourceLocation?
177
192
  def resolve_loc(locations)
178
- return "" unless locations
193
+ return unless locations
179
194
 
195
+ # Find the location of the closest file load, which should give us the location of the file that
196
+ # triggered the definition.
180
197
  resolved_loc = locations.find do |loc|
181
198
  label = loc.label
182
199
  next unless label
183
200
 
184
201
  REQUIRED_FROM_LABELS.include?(label) || label.start_with?("block in <class:")
185
202
  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 || ""
187
216
 
188
- resolved_loc.absolute_path || ""
217
+ SourceLocation.from_loc([file, resolved_loc.lineno])
189
218
  end
190
219
 
191
220
  #: (Module constant) -> Set[String]
@@ -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
@@ -43,7 +43,9 @@ module Tapioca
43
43
  Kernel.define_method(:abort, NOOP_METHOD)
44
44
  Kernel.define_method(:exit, NOOP_METHOD)
45
45
 
46
- block.call
46
+ Tapioca.silence_warnings do
47
+ block.call
48
+ end
47
49
  ensure
48
50
  Kernel.define_method(:exit, original_exit)
49
51
  Kernel.define_method(:abort, original_abort)
@@ -13,12 +13,7 @@ module Tapioca
13
13
  extend Reflection
14
14
  extend T::Sig
15
15
 
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]]
22
17
 
23
18
  # Immediately activated upon load. Observes class/module definition.
24
19
  @class_tracepoint = TracePoint.trace(:class) do |tp|
@@ -28,14 +23,17 @@ module Tapioca
28
23
 
29
24
  path = tp.path
30
25
  if File.exist?(path)
31
- loc = build_constant_location(tp, caller_locations)
26
+ loc = build_source_location(tp, caller_locations)
32
27
  else
33
28
  caller_location = T.must(caller_locations)
34
29
  .find { |loc| loc.path && File.exist?(loc.path) }
35
30
 
36
31
  next unless caller_location
37
32
 
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
+ ])
39
37
  end
40
38
 
41
39
  (@class_files[key] ||= Set.new) << loc
@@ -47,31 +45,37 @@ module Tapioca
47
45
  key = tp.return_value
48
46
  next unless Module === key
49
47
 
50
- loc = build_constant_location(tp, caller_locations)
48
+ loc = build_source_location(tp, caller_locations)
51
49
  (@class_files[key] ||= Set.new) << loc
52
50
  end
53
51
 
54
52
  class << self
53
+ extend T::Sig
54
+
55
55
  def disable!
56
56
  @class_tracepoint.disable
57
57
  @creturn_tracepoint.disable
58
58
  super
59
59
  end
60
60
 
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)
64
66
 
65
- ConstantLocation.new(path: file, lineno: lineno)
67
+ SourceLocation.from_loc([file || "", lineno])
66
68
  end
67
69
 
68
70
  # Returns the files in which this class or module was opened. Doesn't know
69
71
  # about situations where the class was opened prior to +require+ing,
70
72
  # or where metaprogramming was used via +eval+, etc.
73
+ #: (Module klass) -> Set[String]
71
74
  def files_for(klass)
72
- locations_for(klass).map(&:path).to_set
75
+ locations_for(klass).map(&:file).to_set
73
76
  end
74
77
 
78
+ #: (Module klass) -> Set[SourceLocation]
75
79
  def locations_for(klass)
76
80
  @class_files.fetch(klass, Set.new)
77
81
  end
@@ -0,0 +1,65 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Tapioca
5
+ module Runtime
6
+ module Trackers
7
+ module MethodDefinition
8
+ extend Tracker
9
+ extend T::Sig
10
+
11
+ @method_definitions = {}.compare_by_identity #: Hash[Module, Hash[Symbol, Array[SourceLocation]]]
12
+
13
+ class << self
14
+ #: (Symbol method_name, Module owner, Array[Thread::Backtrace::Location] locations) -> void
15
+ def register(method_name, owner, locations)
16
+ return unless enabled?
17
+ # If Sorbet runtime is redefining a method, it sets this to true.
18
+ # In those cases, we should skip the registration, as the method's original
19
+ # definition should already be registered.
20
+ return if T::Private::DeclState.current.skip_on_method_added
21
+
22
+ loc = Reflection.resolve_loc(locations)
23
+ return unless loc
24
+
25
+ registrations_for(method_name, owner) << loc
26
+ end
27
+
28
+ #: (Symbol method_name, Module owner) -> Array[SourceLocation]
29
+ def method_definitions_for(method_name, owner)
30
+ definitions = registrations_for(method_name, owner)
31
+
32
+ if definitions.empty?
33
+ source_loc = owner.instance_method(method_name).source_location
34
+ definitions = [SourceLocation.from_loc(source_loc)].compact
35
+ end
36
+
37
+ definitions
38
+ end
39
+
40
+ private
41
+
42
+ #: (Symbol method_name, Module owner) -> Array[SourceLocation]
43
+ def registrations_for(method_name, owner)
44
+ owner_lookup = (@method_definitions[owner] ||= {})
45
+ owner_lookup[method_name] ||= []
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+
53
+ class Module
54
+ prepend(Module.new do
55
+ def singleton_method_added(method_name)
56
+ Tapioca::Runtime::Trackers::MethodDefinition.register(method_name, singleton_class, caller_locations)
57
+ super
58
+ end
59
+
60
+ def method_added(method_name)
61
+ Tapioca::Runtime::Trackers::MethodDefinition.register(method_name, self, caller_locations)
62
+ super
63
+ end
64
+ end)
65
+ end
@@ -32,8 +32,9 @@ module Tapioca
32
32
  return unless enabled?
33
33
 
34
34
  location = Reflection.resolve_loc(caller_locations)
35
+ return unless location
35
36
 
36
- register_with_location(constant, mixin, mixin_type, location)
37
+ register_with_location(constant, mixin, mixin_type, location.file)
37
38
  end
38
39
 
39
40
  def resolve_to_attached_class(constant, mixin, mixin_type)
@@ -52,3 +52,4 @@ require "tapioca/runtime/trackers/mixin"
52
52
  require "tapioca/runtime/trackers/constant_definition"
53
53
  require "tapioca/runtime/trackers/autoload"
54
54
  require "tapioca/runtime/trackers/required_ancestor"
55
+ require "tapioca/runtime/trackers/method_definition"
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Tapioca
5
- VERSION = "0.17.1"
5
+ VERSION = "0.17.2"
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tapioca
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.17.1
4
+ version: 0.17.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ufuk Kayserilioglu
@@ -281,9 +281,11 @@ files:
281
281
  - lib/tapioca/runtime/dynamic_mixin_compiler.rb
282
282
  - lib/tapioca/runtime/generic_type_registry.rb
283
283
  - lib/tapioca/runtime/reflection.rb
284
+ - lib/tapioca/runtime/source_location.rb
284
285
  - lib/tapioca/runtime/trackers.rb
285
286
  - lib/tapioca/runtime/trackers/autoload.rb
286
287
  - lib/tapioca/runtime/trackers/constant_definition.rb
288
+ - lib/tapioca/runtime/trackers/method_definition.rb
287
289
  - lib/tapioca/runtime/trackers/mixin.rb
288
290
  - lib/tapioca/runtime/trackers/required_ancestor.rb
289
291
  - lib/tapioca/runtime/trackers/tracker.rb