require-hooks 0.3.0 → 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 36e7f23cef600d120d9139555f3e3ef4b8bd46b9929387b99dce99280108615c
4
- data.tar.gz: c89a896109b87d81d2e1a7690398bc337b6723c2ae006c5a2a0f0c7a8e01c52a
3
+ metadata.gz: 046c644cbb446d1abd3263c3c1ee0c578cddac17693d102172e123f1a880966a
4
+ data.tar.gz: a102736a0665a9de69c9aba242033a479af9b090bf135fa729e74b9bd8f6e006
5
5
  SHA512:
6
- metadata.gz: 4af96e3f1d117b10e7cedc4cb564e47768ac5eb44e8f83e666e90b9f98178fd827a19641caa0f2dd3e674736ec7aa0982e11d184fa780e46fc89a0c399098f00
7
- data.tar.gz: c0c60d8e40d22743aa24c671b88989c6aef61c58d39cdefe39e26c73905fc08286d0fb68d09e409e0762e8ccbfe5334926d7c35321f2442bf4158e5e8dda0d1e
6
+ metadata.gz: 35e088739e021cc4e83827122ce2daa63934439f6e4d0c9597ccafdaf6f6a08b50eba9302cfed1ced10538a62b317406ded4da1882b06f4d04b527a5e4902b47
7
+ data.tar.gz: 801d491df8a08926067f47b44ee2372d73a53fada8cdfe36b62c0d66191f52f6900499121fc87a326125f00e443848734e5f7756df541d7e92f688ae293e5026
data/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## master
4
4
 
5
+ ## 0.4.0 (2026-04-29)
6
+
7
+ - Improved Bootsnap cache invalidation logic on hooks configuration changes.
8
+
9
+ - Latest Bootsnap compatibility
10
+
11
+ - Coverage compatibility (w/ some limitations)
12
+
5
13
  ## 0.3.0 (2026-04-22)
6
14
 
7
15
  - Fix the order of around hooks execution (after part) when using `#load_iseq` driven hooks.
data/README.md CHANGED
@@ -9,6 +9,8 @@ Require Hooks is a library providing universal interface for injecting custom co
9
9
 
10
10
  Require hooks allows you to interfere with `Kernel#require` (incl. `Kernel#require_relative`) and `Kernel#load`.
11
11
 
12
+ > Check the ["Require Hooks: Filling the Gap in Ruby's Extensibility"](https://evilmartians.com/events/require-hooks-rubykaigi) talk from RubyKaigi 2026 to learn more.
13
+
12
14
  <a href="https://evilmartians.com/">
13
15
  <img src="https://evilmartians.com/badges/sponsored-by-evil-martians.svg" alt="Sponsored by Evil Martians" width="236" height="54"></a>
14
16
 
@@ -126,6 +128,7 @@ Thus, if you introduce new source transformers or hijackers, you must invalidate
126
128
 
127
129
  ## Limitations
128
130
 
131
+ - Coverage tracking is only supported if `eval` coverage tracking is enabled (`Coverage.start(eval: true, ...)` or `Simplecov.start { enable_coverage_for_eval; ... }`). Currently requires **Ruby 3.4+**.
129
132
  - `Kernel#load` with a wrap argument (e.g., `load "some_path", true` or `load "some_path", MyModule)`) is not supported (fallbacked to the original implementation). The biggest challenge here is to support constants nesting.
130
133
  - Some very edgy symlinking scenarios are not supported (unlikely to affect real-world projects).
131
134
 
@@ -175,9 +178,9 @@ Test script: `time bundle exec rails runner 'puts "done"'`.
175
178
  | rhooks (patch)  | **8m** |
176
179
  | rhooks (bootsnap)  | 12s |
177
180
 
178
- You can see that requiring tons of files with Require Hooks in patch mode is very slow for now. Why? Mostly because we MUST check `$LOADED_FEATURES` for the presence of the file we want to load and currently we do this via `$LOADED_FEATURES.include?(path)` call, which becomes very slow when `$LOADED_FEATURES` is huge. Thus, we recommend activating Require Hooks after loading all the dependencies and limiting the scope of affected files (via the `patterns` option) on non-MRI platforms to avoid this overhead.
181
+ You can see that requiring tons of files with Require Hooks in patch mode is very slow for now. Why? Manipulating the `$LOADED_FEATURES` index from Ruby triggers costly invalidation at the VM side when a regular `#require` occurs. We recommend activating Require Hooks after loading all the dependencies and limiting the scope of affected files (via the `patterns` option) on non-MRI platforms to avoid this overhead.
179
182
 
180
- **NOTE:** Why Ruby's internal implementations is fast despite from doing the same checks? It uses an internal hash table to keep track of the loaded features (`vm->loaded_features_realpaths`), not an array. Unfortunately, it's not accessible from Ruby.
183
+ **NOTE:** This could be improved in the future if we get [optimized API](https://github.com/palkan/ruby/pull/1) for managing `$LOADED_FEATURES` from Ruby code.
181
184
 
182
185
  Here are the numbers for the same project with scoped hooks (only some folders) activated after `Bundler.require(*)`:
183
186
 
@@ -14,6 +14,7 @@ module RequireHooks
14
14
  @hijack_load = []
15
15
 
16
16
  @empty = nil
17
+ @readonly = nil
17
18
  end
18
19
 
19
20
  def to_key
@@ -31,6 +32,12 @@ module RequireHooks
31
32
  @empty = @around_load.empty? && @source_transform.empty? && @hijack_load.empty?
32
33
  end
33
34
 
35
+ def readonly?
36
+ return @readonly unless @readonly.nil?
37
+
38
+ @readonly = @source_transform.empty? && @hijack_load.empty?
39
+ end
40
+
34
41
  def source_transform?
35
42
  @source_transform.any?
36
43
  end
@@ -70,6 +77,12 @@ module RequireHooks
70
77
  end
71
78
  nil
72
79
  end
80
+
81
+ def merge!(another_ctx)
82
+ around_load.concat(another_ctx.around_load)
83
+ source_transform.concat(another_ctx.source_transform)
84
+ hijack_load.concat(another_ctx.hijack_load)
85
+ end
73
86
  end
74
87
 
75
88
  @@default_context = Context.new
@@ -88,13 +101,7 @@ module RequireHooks
88
101
  # block.call.tap { puts "Loaded #{path}" }
89
102
  # end
90
103
  def around_load(patterns: nil, exclude_patterns: nil, &block)
91
- @@default_context = nil
92
- ctx = Context.new(patterns: patterns, exclude_patterns: exclude_patterns)
93
-
94
- @@contexts[ctx.to_key] ||= ctx
95
- @@contexts[ctx.to_key].around_load << block
96
-
97
- @@default_context = @@contexts.values.first if @@contexts.size == 1
104
+ register_hook(:around_load, block, patterns: patterns, exclude_patterns: exclude_patterns)
98
105
  end
99
106
 
100
107
  # Define hooks to perform source-to-source transformations.
@@ -108,13 +115,7 @@ module RequireHooks
108
115
  # "# frozen_string_literal: true\n#{source}"
109
116
  # end
110
117
  def source_transform(patterns: nil, exclude_patterns: nil, &block)
111
- @@default_context = nil
112
- ctx = Context.new(patterns: patterns, exclude_patterns: exclude_patterns)
113
-
114
- @@contexts[ctx.to_key] ||= ctx
115
- @@contexts[ctx.to_key].source_transform << block
116
-
117
- @@default_context = @@contexts.values.first if @@contexts.size == 1
118
+ register_hook(:source_transform, block, patterns: patterns, exclude_patterns: exclude_patterns)
118
119
  end
119
120
 
120
121
  # This hook should be used to manually compile byte code to be loaded by the VM.
@@ -132,21 +133,15 @@ module RequireHooks
132
133
  # end
133
134
  # end
134
135
  def hijack_load(patterns: nil, exclude_patterns: nil, &block)
135
- @@default_context = nil
136
- ctx = Context.new(patterns: patterns, exclude_patterns: exclude_patterns)
137
-
138
- @@contexts[ctx.to_key] ||= ctx
139
- @@contexts[ctx.to_key].hijack_load << block
140
-
141
- @@default_context = @@contexts.values.first if @@contexts.size == 1
136
+ register_hook(:hijack_load, block, patterns: patterns, exclude_patterns: exclude_patterns)
142
137
  end
143
138
 
144
139
  def context_for(path)
145
- # Fast-track in case we have just a single context defined
140
+ # Fast-track in case we have just a single non-global context defined
146
141
  if @@default_context
147
- return @@noop_context unless @@default_context.match?(path)
142
+ return @@default_context if @@default_context.match?(path)
148
143
 
149
- return @@default_context
144
+ return @@noop_context
150
145
  end
151
146
 
152
147
  matching = @@contexts.values.select { |ctx| ctx.match?(path) }
@@ -154,13 +149,44 @@ module RequireHooks
154
149
  return matching[0] || @@noop_context if matching.size < 2
155
150
 
156
151
  ctx = Context.new
157
- matching.each do |mctx|
158
- ctx.around_load.concat(mctx.around_load)
159
- ctx.source_transform.concat(mctx.source_transform)
160
- ctx.hijack_load.concat(mctx.hijack_load)
161
- end
152
+ matching.each { |mctx| ctx.merge!(mctx) }
162
153
 
163
154
  ctx
164
155
  end
156
+
157
+ # Hack to enable coverage for hooked files.
158
+ # Requires eval coverage to be on.
159
+ # See https://bugs.ruby-lang.org/issues/22018 (https://github.com/ruby/ruby/pull/16805)
160
+ def setup_path_coverage(path, contents = nil)
161
+ return unless defined?(Coverage) && Coverage.running?
162
+
163
+ return unless eval_coverage_enabled?
164
+
165
+ Kernel.eval("\n" * (contents || File.read(path)).lines.size, TOPLEVEL_BINDING, path, 1) # rubocop:disable Style/EvalWithLocation,Security/Eval
166
+ end
167
+
168
+ def contexts
169
+ @@contexts
170
+ end
171
+
172
+ private
173
+
174
+ def register_hook(type, block, patterns: nil, exclude_patterns: nil)
175
+ @@default_context = nil
176
+ ctx = Context.new(patterns: patterns, exclude_patterns: exclude_patterns)
177
+
178
+ @@contexts[ctx.to_key] ||= ctx
179
+ @@contexts[ctx.to_key].public_send(type) << block
180
+
181
+ @@default_context = @@contexts.values.first if @@contexts.size == 1
182
+ end
183
+
184
+ def eval_coverage_enabled?
185
+ return @eval_coverage_enabled if defined?(@eval_coverage_enabled)
186
+ probe_path = File.join(__dir__, "coverage_probe.rb")
187
+ Kernel.eval("proc { |val| val }", TOPLEVEL_BINDING, probe_path, 1) # rubocop:disable Style/EvalWithLocation
188
+
189
+ @eval_coverage_enabled = Coverage.peek_result.key?(probe_path)
190
+ end
165
191
  end
166
192
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RequireHooks
4
+ module Iseq
5
+ class << self
6
+ def compile_with_coverage(ctx, path)
7
+ iseq =
8
+ if ctx.source_transform? || ctx.hijack?
9
+ new_contents = ctx.perform_source_transform(path)
10
+
11
+ RequireHooks.setup_path_coverage(path, new_contents)
12
+
13
+ hijacked = ctx.try_hijack_load(path, new_contents)
14
+
15
+ if hijacked
16
+ raise TypeError, "Unsupported bytecode format for #{path}: #{hijack.class}" unless hijacked.is_a?(::RubyVM::InstructionSequence)
17
+ hijacked
18
+ elsif new_contents
19
+ RubyVM::InstructionSequence.compile(new_contents, path, path, 1)
20
+ end
21
+ end
22
+
23
+ RequireHooks.setup_path_coverage(path, new_contents)
24
+
25
+ iseq ||= yield if block_given?
26
+
27
+ iseq ||= RubyVM::InstructionSequence.compile_file(path)
28
+
29
+ iseq
30
+ end
31
+ end
32
+ end
33
+ end
@@ -1,9 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "require-hooks/iseq"
4
+
3
5
  module RequireHooks
4
6
  module Bootsnap
5
7
  EMPTY_ISEQ = RubyVM::InstructionSequence.compile("").freeze
6
8
 
9
+ # For older Bootsnap
7
10
  module CompileCacheExt
8
11
  def input_to_storage(source, path, *)
9
12
  ctx = RequireHooks.context_for(path)
@@ -25,25 +28,87 @@ module RequireHooks
25
28
  end
26
29
  end
27
30
 
31
+ # For new Bootsnap
32
+ module CompilerExt
33
+ def input_to_storage(source, path, *)
34
+ ctx = RequireHooks.context_for(path)
35
+ return super if ctx.empty?
36
+
37
+ new_contents = ctx.perform_source_transform(path)
38
+ hijacked = ctx.try_hijack_load(path, new_contents)
39
+
40
+ if hijacked
41
+ raise TypeError, "Unsupported bytecode format for #{path}: #{hijack.class}" unless hijacked.is_a?(::RubyVM::InstructionSequence)
42
+ return hijacked.to_binary
43
+ elsif new_contents
44
+ return RubyVM::InstructionSequence.compile(new_contents, path, path, 1, @compile_options).to_binary
45
+ end
46
+
47
+ super
48
+ rescue SyntaxError, TypeError
49
+ ::Bootsnap::CompileCache::UNCOMPILABLE
50
+ end
51
+ end
52
+
28
53
  module LoadIseqExt
54
+ class << self
55
+ attr_accessor :orig_cache_dir
56
+ end
57
+
29
58
  # Around hooks must be performed every time we trigger a file load, even if
30
59
  # the file is already cached.
31
60
  def load_iseq(path)
32
61
  ctx = RequireHooks.context_for(path)
33
62
  # Early-return for non-trackable paths
34
- return super if ctx.empty?
63
+ if ctx.empty?
64
+ ::Bootsnap::CompileCache::ISeq.cache_dir = LoadIseqExt.orig_cache_dir if LoadIseqExt.orig_cache_dir
65
+ return super
66
+ end
67
+
68
+ LoadIseqExt.orig_cache_dir ||= ::Bootsnap::CompileCache::ISeq.cache_dir
69
+ ::Bootsnap::CompileCache::ISeq.cache_dir = File.join(LoadIseqExt.orig_cache_dir, RequireHooks::Bootsnap.version_hash)
35
70
 
36
71
  ctx.run_around_load_callbacks(path) do
37
72
  iseq = super
38
- return unless iseq
73
+
74
+ ::Bootsnap::CompileCache::ISeq.cache_dir = LoadIseqExt.orig_cache_dir
75
+
76
+ # Bootsnap returns nil when the coverage is on,
77
+ # we fallback to our custom #compile_with_coverage
78
+ unless iseq
79
+ next unless defined?(Coverage) && Coverage.running?
80
+
81
+ iseq = RequireHooks::Iseq.compile_with_coverage(ctx, path)
82
+ end
39
83
 
40
84
  iseq.eval
41
85
  EMPTY_ISEQ
86
+ ensure
87
+ ::Bootsnap::CompileCache::ISeq.cache_dir = LoadIseqExt.orig_cache_dir
42
88
  end
43
89
  end
44
90
  end
91
+
92
+ class << self
93
+ def version_hash
94
+ @version_key ||= RequireHooks.contexts.values.map(&:to_cache_key).join("-")
95
+ end
96
+
97
+ attr_writer :version_hash
98
+ end
99
+ end
100
+
101
+ class Context
102
+ def to_cache_key
103
+ Zlib.crc32(
104
+ (around_load + source_transform + hijack_load).map do |pr|
105
+ RubyVM::InstructionSequence.disasm(pr)
106
+ end.join("\n")
107
+ ).to_s
108
+ end
45
109
  end
46
110
  end
47
111
 
48
112
  Bootsnap::CompileCache::ISeq.singleton_class.prepend(RequireHooks::Bootsnap::CompileCacheExt)
113
+ Bootsnap::CompileCache::ISeq::Compiler.prepend(RequireHooks::Bootsnap::CompilerExt) if defined?(Bootsnap::CompileCache::ISeq::Compiler)
49
114
  RubyVM::InstructionSequence.singleton_class.prepend(RequireHooks::Bootsnap::LoadIseqExt)
@@ -14,7 +14,9 @@ module RequireHooks
14
14
  new_contents = ctx.perform_source_transform(path)
15
15
  hijacked = ctx.try_hijack_load(path, new_contents)
16
16
 
17
- return try_evaluate(path, hijacked) if hijacked
17
+ if hijacked
18
+ return try_evaluate(path, hijacked)
19
+ end
18
20
 
19
21
  if new_contents
20
22
  evaluate(new_contents, path)
@@ -29,6 +31,7 @@ module RequireHooks
29
31
 
30
32
  def try_evaluate(path, bytecode)
31
33
  if defined?(::RubyVM::InstructionSequence) && bytecode.is_a?(::RubyVM::InstructionSequence)
34
+ RequireHooks.setup_path_coverage(path)
32
35
  bytecode.eval
33
36
  else
34
37
  raise TypeError, "Unknown bytecode format for #{path}: #{bytecode.inspect}"
@@ -49,6 +52,8 @@ module RequireHooks
49
52
  end
50
53
  else
51
54
  def evaluate(code, filepath)
55
+ RequireHooks.setup_path_coverage(filepath, code)
56
+
52
57
  # This is workaround to solve the "leaking refinements" problem in MRI
53
58
  RubyVM::InstructionSequence.compile(code, filepath).tap do |iseq|
54
59
  iseq.eval
@@ -226,22 +231,24 @@ module Kernel
226
231
 
227
232
  return false if $LOADED_FEATURES.include?(realpath)
228
233
 
229
- RequireHooks::KernelPatch::Features::LOCK.lock_feature(feature) do |loaded|
230
- return false if loaded
234
+ begin
235
+ RequireHooks::KernelPatch::Features::LOCK.lock_feature(feature) do |loaded|
236
+ return false if loaded
231
237
 
232
- $LOADED_FEATURES << realpath
233
- RequireHooks::KernelPatch.load(realpath, ctx: ctx)
234
- true
238
+ $LOADED_FEATURES << realpath
239
+ RequireHooks::KernelPatch.load(realpath, ctx: ctx)
240
+ true
241
+ end
242
+ rescue LoadError => e
243
+ $LOADED_FEATURES.delete(realpath) if realpath
244
+ warn "RequireHooks failed to require '#{path}' from '#{realpath}': #{e.message}" if RequireHooks.print_warnings
245
+ require_without_require_hooks(path)
246
+ rescue Errno::ENOENT, Errno::EACCES
247
+ raise LoadError, "cannot load such file -- #{path}"
248
+ rescue
249
+ $LOADED_FEATURES.delete(realpath) if realpath
250
+ raise
235
251
  end
236
- rescue LoadError => e
237
- $LOADED_FEATURES.delete(realpath) if realpath
238
- warn "RequireHooks failed to require '#{path}': #{e.message}" if RequireHooks.print_warnings
239
- require_without_require_hooks(path)
240
- rescue Errno::ENOENT, Errno::EACCES
241
- raise LoadError, "cannot load such file -- #{path}"
242
- rescue
243
- $LOADED_FEATURES.delete(realpath) if realpath
244
- raise
245
252
  end
246
253
 
247
254
  alias_method :require_relative_without_require_hooks, :require_relative
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "require-hooks/iseq"
4
+
3
5
  module RequireHooks
4
6
  EMPTY_ISEQ = RubyVM::InstructionSequence.compile("").freeze
5
7
 
@@ -11,20 +13,8 @@ module RequireHooks
11
13
  return if ctx.empty?
12
14
 
13
15
  ctx.run_around_load_callbacks(path) do
14
- iseq =
15
- if ctx.source_transform? || ctx.hijack?
16
- new_contents = ctx.perform_source_transform(path)
17
- hijacked = ctx.try_hijack_load(path, new_contents)
18
-
19
- if hijacked
20
- raise TypeError, "Unsupported bytecode format for #{path}: #{hijack.class}" unless hijacked.is_a?(::RubyVM::InstructionSequence)
21
- hijacked
22
- elsif new_contents
23
- RubyVM::InstructionSequence.compile(new_contents, path, path, 1)
24
- end
25
- end
26
-
27
- iseq ||= (defined?(super) ? super : RubyVM::InstructionSequence.compile_file(path))
16
+ iseq = RequireHooks::Iseq.compile_with_coverage(ctx, path) { defined?(super) && super }
17
+
28
18
  iseq.eval
29
19
  EMPTY_ISEQ
30
20
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RequireHooks
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: require-hooks
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vladimir Dementyev
@@ -22,6 +22,7 @@ files:
22
22
  - README.md
23
23
  - lib/require-hooks.rb
24
24
  - lib/require-hooks/api.rb
25
+ - lib/require-hooks/iseq.rb
25
26
  - lib/require-hooks/mode/bootsnap.rb
26
27
  - lib/require-hooks/mode/kernel_patch.rb
27
28
  - lib/require-hooks/mode/load_iseq.rb