Skip to content

Commit 9344dd5

Browse files
committed
Add ReloadableRequire for hot-reloading files in IRB
Introduce ReloadableRequire module that enables reloading of files loaded via require/require_relative in IRB sessions. This feature requires Ruby::Box (Ruby 4.0+) and can be enabled with: IRB.conf[:RELOADABLE_REQUIRE] = true The module tracks loaded Ruby files and allows them to be reloaded while keeping native extensions loaded normally.
1 parent b29cc2c commit 9344dd5

3 files changed

Lines changed: 145 additions & 0 deletions

File tree

lib/irb/init.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,10 @@ def IRB.init_config(ap_path)
196196
}
197197

198198
@CONF[:COPY_COMMAND] = ENV.fetch("IRB_COPY_COMMAND", nil)
199+
200+
@CONF[:RELOADABLE_REQUIRE] = false
201+
@CONF[:__RELOADABLE_FILES__] = Set.new
202+
@CONF[:__AUTOLOAD_FILES__] = Set.new
199203
end
200204

201205
def IRB.set_measure_callback(type = nil, arg = nil, &block)

lib/irb/reloadable_require.rb

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
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+
Ruby::Box.class_eval do
41+
alias_method :__irb_original_require__, :require
42+
alias_method :__irb_original_require_relative__, :require_relative
43+
44+
def __irb_reloadable_require__(feature)
45+
unless IRB.conf[:__AUTOLOAD_FILES__].include?(feature)
46+
return __irb_original_require__(feature)
47+
end
48+
49+
IRB.conf[:__AUTOLOAD_FILES__].delete(feature)
50+
IRB.track_and_load_files($LOADED_FEATURES, Ruby::Box.main) { __irb_original_require__(feature) }
51+
end
52+
53+
def __irb_reloadable_require_relative__(feature)
54+
__irb_original_require_relative__(feature)
55+
end
56+
end
57+
58+
module ReloadableRequire
59+
class << self
60+
def extended(base)
61+
apply_autoload_hook
62+
end
63+
64+
def apply_autoload_hook
65+
Ruby::Box.class_eval do
66+
alias_method :require, :__irb_reloadable_require__
67+
alias_method :require_relative, :__irb_reloadable_require_relative__
68+
end
69+
end
70+
end
71+
72+
private
73+
74+
def reloadable_require_internal(absolute_path, caller_box)
75+
return false if caller_box.eval('$LOADED_FEATURES').include?(absolute_path)
76+
77+
box = Ruby::Box.new
78+
load_path = caller_box.eval('$LOAD_PATH')
79+
# Copy $LOAD_PATH to the box so it can resolve dependencies.
80+
box.eval("$LOAD_PATH.concat(#{load_path})")
81+
82+
IRB.track_and_load_files(box.eval('$LOADED_FEATURES'), caller_box) { box.__irb_original_require__(absolute_path) }
83+
end
84+
85+
def require(feature)
86+
caller_loc = caller_locations(1, 1).first
87+
current_box = Ruby::Box.main
88+
resolved = current_box.eval("$LOAD_PATH.resolve_feature_path(#{feature.dump})")
89+
90+
# Fallback for calls outside IRB prompt
91+
if caller_loc.path != "(irb)" || !resolved || resolved[0] != :rb
92+
return current_box.require(feature)
93+
end
94+
95+
reloadable_require_internal(resolved[1], 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+
# Fallback for calls outside IRB prompt
103+
if caller_loc.path != "(irb)"
104+
file_path = caller_loc.absolute_path || caller_loc.path
105+
return current_box.eval("eval('Kernel.require_relative(#{feature.dump})', nil, #{file_path.dump}, #{caller_loc.lineno})")
106+
end
107+
108+
absolute_path = resolve_require_relative_path(feature)
109+
110+
if !absolute_path.end_with?('.rb') || !File.exist?(absolute_path)
111+
file_path = File.join(Dir.pwd, "(irb)")
112+
return current_box.eval("eval('Kernel.require_relative(#{feature.dump})', nil, #{file_path.dump}, 1)")
113+
end
114+
115+
reloadable_require_internal(absolute_path, current_box)
116+
end
117+
118+
def resolve_require_relative_path(feature)
119+
absolute_path = File.expand_path(feature, Dir.pwd)
120+
return absolute_path unless File.extname(absolute_path).empty?
121+
122+
dlext = RbConfig::CONFIG['DLEXT']
123+
['.rb', ".#{dlext}"].each do |ext|
124+
candidate = absolute_path + ext
125+
return candidate if File.exist?(candidate)
126+
end
127+
128+
absolute_path
129+
end
130+
131+
def autoload(const, feature)
132+
IRB.conf[:__AUTOLOAD_FILES__] << feature
133+
Ruby::Box.main.eval("Kernel.autoload(:#{const}, #{feature.dump})")
134+
end
135+
end
136+
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)