Skip to content

Commit 60d9ecc

Browse files
committed
feat: add ReloadableRequire for hot-reloading files in IRB
1 parent 8a1129d commit 60d9ecc

2 files changed

Lines changed: 120 additions & 0 deletions

File tree

lib/irb/reloadable_require.rb

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# frozen_string_literal: true
2+
3+
if !defined?(Ruby::Box) || !Ruby::Box.enabled?
4+
raise "ReloadableRequire requires Ruby::Box to be enabled"
5+
end
6+
7+
module IRB
8+
# Provides reload-aware require functionality for IRB.
9+
#
10+
# Limitations:
11+
# - Native extensions cannot be reloaded (load doesn't support them)
12+
# - Files loaded via box.require are not tracked
13+
# - Constant redefinition warnings will appear on reload (uses load internally)
14+
# - Context mode 5 (running IRB inside a Ruby::Box) is not supported
15+
#
16+
# This feature requires Ruby::Box (Ruby 4.0+).
17+
18+
class << self
19+
def track_and_load_files(source, current_box)
20+
before = source.dup
21+
result = yield
22+
new_files = source - before
23+
24+
return result if new_files.empty?
25+
26+
ruby_files, native_extensions = new_files.partition { |path| path.end_with?('.rb') }
27+
28+
native_extensions.each { |path| current_box.require(path) }
29+
30+
IRB.conf[:__RELOADABLE_FILES__].merge(ruby_files)
31+
32+
main_loaded_features = current_box.eval('$LOADED_FEATURES')
33+
main_loaded_features.concat(ruby_files - main_loaded_features)
34+
ruby_files.each { |path| current_box.load(path) }
35+
36+
result
37+
end
38+
end
39+
40+
unless Ruby::Box.method_defined?(:__irb_original_require__)
41+
Ruby::Box.class_eval do
42+
alias_method :__irb_original_require__, :require
43+
alias_method :__irb_original_require_relative__, :require_relative
44+
end
45+
end
46+
47+
Ruby::Box.class_eval do
48+
49+
def __irb_reloadable_require__(feature)
50+
unless IRB.conf[:__AUTOLOAD_FILES__].include?(feature)
51+
return __irb_original_require__(feature)
52+
end
53+
54+
IRB.conf[:__AUTOLOAD_FILES__].delete(feature)
55+
IRB.track_and_load_files($LOADED_FEATURES, Ruby::Box.main) { __irb_original_require__(feature) }
56+
end
57+
58+
def __irb_reloadable_require_relative__(feature)
59+
__irb_original_require_relative__(feature)
60+
end
61+
end
62+
63+
module ReloadableRequire
64+
class << self
65+
def extended(base)
66+
apply_autoload_hook
67+
end
68+
69+
def apply_autoload_hook
70+
Ruby::Box.class_eval do
71+
alias_method :require, :__irb_reloadable_require__
72+
alias_method :require_relative, :__irb_reloadable_require_relative__
73+
end
74+
end
75+
end
76+
77+
private
78+
79+
def reloadable_require_internal(feature, caller_box)
80+
box = Ruby::Box.new
81+
box.eval("$LOAD_PATH.concat(#{caller_box.eval('$LOAD_PATH')})")
82+
box.eval("$LOADED_FEATURES.concat(#{caller_box.eval('$LOADED_FEATURES')})")
83+
84+
IRB.track_and_load_files(box.eval('$LOADED_FEATURES'), caller_box) { box.__irb_original_require__(feature) }
85+
end
86+
87+
def require(feature)
88+
caller_loc = caller_locations(1, 1).first
89+
current_box = Ruby::Box.main
90+
return current_box.__irb_original_require__(feature) unless caller_loc.path.end_with?("(irb)")
91+
92+
resolved = current_box.eval("$LOAD_PATH.resolve_feature_path(#{feature.dump})")
93+
return current_box.__irb_original_require__(feature) unless resolved&.first == :rb
94+
95+
reloadable_require_internal(feature, current_box)
96+
end
97+
98+
def require_relative(feature)
99+
caller_loc = caller_locations(1, 1).first
100+
current_box = Ruby::Box.main
101+
102+
unless caller_loc.path.end_with?("(irb)")
103+
file_path = caller_loc.absolute_path || caller_loc.path
104+
return current_box.eval("eval('Kernel.require_relative(#{feature.dump})', nil, #{file_path.dump}, #{caller_loc.lineno})")
105+
end
106+
107+
reloadable_require_internal(File.expand_path(feature, Dir.pwd), current_box)
108+
end
109+
110+
def autoload(const, feature)
111+
IRB.conf[:__AUTOLOAD_FILES__] << feature
112+
Ruby::Box.main.eval("Kernel.autoload(:#{const}, #{feature.dump})")
113+
end
114+
end
115+
end

lib/irb/workspace.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
#
66

77
require_relative "helper_method"
8+
require_relative "reloadable_require" if defined?(Ruby::Box) && Ruby::Box.enabled?
89

910
IRB::TOPLEVEL_BINDING = binding
1011
module IRB # :nodoc:
@@ -103,6 +104,10 @@ def load_helper_methods_to_main
103104
ancestors = class<<main;ancestors;end
104105
main.extend ExtendCommandBundle if !ancestors.include?(ExtendCommandBundle)
105106
main.extend HelpersContainer if !ancestors.include?(HelpersContainer)
107+
108+
if IRB.conf[:RELOADABLE_REQUIRE] && defined?(ReloadableRequire) && !ancestors.include?(ReloadableRequire)
109+
main.extend ReloadableRequire
110+
end
106111
end
107112

108113
# Evaluate the given +statements+ within the context of this workspace.

0 commit comments

Comments
 (0)