Skip to content

Commit d6bf311

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

2 files changed

Lines changed: 146 additions & 0 deletions

File tree

lib/irb/reloadable_require.rb

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
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(absolute_path, caller_box)
80+
return false if caller_box.eval('$LOADED_FEATURES').include?(absolute_path)
81+
82+
box = Ruby::Box.new
83+
load_path = caller_box.eval('$LOAD_PATH')
84+
# Copy $LOAD_PATH to the box so it can resolve dependencies.
85+
box.eval("$LOAD_PATH.concat(#{load_path})")
86+
87+
IRB.track_and_load_files(box.eval('$LOADED_FEATURES'), caller_box) { box.__irb_original_require__(absolute_path) }
88+
end
89+
90+
def require(feature)
91+
caller_loc = caller_locations(1, 1).first
92+
current_box = Ruby::Box.main
93+
resolved = current_box.eval("$LOAD_PATH.resolve_feature_path(#{feature.dump})")
94+
95+
# Fallback for calls outside IRB prompt
96+
if caller_loc.path != "(irb)" || !resolved || resolved[0] != :rb
97+
return current_box.require(feature)
98+
end
99+
100+
reloadable_require_internal(resolved[1], current_box)
101+
end
102+
103+
def require_relative(feature)
104+
caller_loc = caller_locations(1, 1).first
105+
current_box = Ruby::Box.main
106+
107+
# Fallback for calls outside IRB prompt
108+
if caller_loc.path != "(irb)"
109+
file_path = caller_loc.absolute_path || caller_loc.path
110+
return current_box.eval("eval('Kernel.require_relative(#{feature.dump})', nil, #{file_path.dump}, #{caller_loc.lineno})")
111+
end
112+
113+
absolute_path = resolve_require_relative_path(feature)
114+
115+
if !absolute_path.end_with?('.rb') || !File.exist?(absolute_path)
116+
file_path = File.join(Dir.pwd, "(irb)")
117+
return current_box.eval("eval('Kernel.require_relative(#{feature.dump})', nil, #{file_path.dump}, 1)")
118+
end
119+
120+
reloadable_require_internal(absolute_path, current_box)
121+
end
122+
123+
def resolve_require_relative_path(feature)
124+
absolute_path = File.expand_path(feature, Dir.pwd)
125+
return absolute_path unless File.extname(absolute_path).empty?
126+
127+
dlext = RbConfig::CONFIG['DLEXT']
128+
['.rb', ".#{dlext}"].each do |ext|
129+
candidate = absolute_path + ext
130+
return candidate if File.exist?(candidate)
131+
end
132+
133+
absolute_path
134+
end
135+
136+
def autoload(const, feature)
137+
IRB.conf[:__AUTOLOAD_FILES__] << feature
138+
Ruby::Box.main.eval("Kernel.autoload(:#{const}, #{feature.dump})")
139+
end
140+
end
141+
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)