From 60d9eccdf5a0c19ccb7279b3cd60ded821731c58 Mon Sep 17 00:00:00 2001 From: ahogappa Date: Thu, 26 Mar 2026 23:30:02 +0000 Subject: [PATCH 1/7] feat: add ReloadableRequire for hot-reloading files in IRB --- lib/irb/reloadable_require.rb | 115 ++++++++++++++++++++++++++++++++++ lib/irb/workspace.rb | 5 ++ 2 files changed, 120 insertions(+) create mode 100644 lib/irb/reloadable_require.rb diff --git a/lib/irb/reloadable_require.rb b/lib/irb/reloadable_require.rb new file mode 100644 index 000000000..798524878 --- /dev/null +++ b/lib/irb/reloadable_require.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +if !defined?(Ruby::Box) || !Ruby::Box.enabled? + raise "ReloadableRequire requires Ruby::Box to be enabled" +end + +module IRB + # Provides reload-aware require functionality for IRB. + # + # Limitations: + # - Native extensions cannot be reloaded (load doesn't support them) + # - Files loaded via box.require are not tracked + # - Constant redefinition warnings will appear on reload (uses load internally) + # - Context mode 5 (running IRB inside a Ruby::Box) is not supported + # + # This feature requires Ruby::Box (Ruby 4.0+). + + class << self + def track_and_load_files(source, current_box) + before = source.dup + result = yield + new_files = source - before + + return result if new_files.empty? + + ruby_files, native_extensions = new_files.partition { |path| path.end_with?('.rb') } + + native_extensions.each { |path| current_box.require(path) } + + IRB.conf[:__RELOADABLE_FILES__].merge(ruby_files) + + main_loaded_features = current_box.eval('$LOADED_FEATURES') + main_loaded_features.concat(ruby_files - main_loaded_features) + ruby_files.each { |path| current_box.load(path) } + + result + end + end + + unless Ruby::Box.method_defined?(:__irb_original_require__) + Ruby::Box.class_eval do + alias_method :__irb_original_require__, :require + alias_method :__irb_original_require_relative__, :require_relative + end + end + + Ruby::Box.class_eval do + + def __irb_reloadable_require__(feature) + unless IRB.conf[:__AUTOLOAD_FILES__].include?(feature) + return __irb_original_require__(feature) + end + + IRB.conf[:__AUTOLOAD_FILES__].delete(feature) + IRB.track_and_load_files($LOADED_FEATURES, Ruby::Box.main) { __irb_original_require__(feature) } + end + + def __irb_reloadable_require_relative__(feature) + __irb_original_require_relative__(feature) + end + end + + module ReloadableRequire + class << self + def extended(base) + apply_autoload_hook + end + + def apply_autoload_hook + Ruby::Box.class_eval do + alias_method :require, :__irb_reloadable_require__ + alias_method :require_relative, :__irb_reloadable_require_relative__ + end + end + end + + private + + def reloadable_require_internal(feature, caller_box) + box = Ruby::Box.new + box.eval("$LOAD_PATH.concat(#{caller_box.eval('$LOAD_PATH')})") + box.eval("$LOADED_FEATURES.concat(#{caller_box.eval('$LOADED_FEATURES')})") + + IRB.track_and_load_files(box.eval('$LOADED_FEATURES'), caller_box) { box.__irb_original_require__(feature) } + end + + def require(feature) + caller_loc = caller_locations(1, 1).first + current_box = Ruby::Box.main + return current_box.__irb_original_require__(feature) unless caller_loc.path.end_with?("(irb)") + + resolved = current_box.eval("$LOAD_PATH.resolve_feature_path(#{feature.dump})") + return current_box.__irb_original_require__(feature) unless resolved&.first == :rb + + reloadable_require_internal(feature, current_box) + end + + def require_relative(feature) + caller_loc = caller_locations(1, 1).first + current_box = Ruby::Box.main + + unless caller_loc.path.end_with?("(irb)") + file_path = caller_loc.absolute_path || caller_loc.path + return current_box.eval("eval('Kernel.require_relative(#{feature.dump})', nil, #{file_path.dump}, #{caller_loc.lineno})") + end + + reloadable_require_internal(File.expand_path(feature, Dir.pwd), current_box) + end + + def autoload(const, feature) + IRB.conf[:__AUTOLOAD_FILES__] << feature + Ruby::Box.main.eval("Kernel.autoload(:#{const}, #{feature.dump})") + end + end +end diff --git a/lib/irb/workspace.rb b/lib/irb/workspace.rb index 9fef8f86a..dee974ec5 100644 --- a/lib/irb/workspace.rb +++ b/lib/irb/workspace.rb @@ -5,6 +5,7 @@ # require_relative "helper_method" +require_relative "reloadable_require" if defined?(Ruby::Box) && Ruby::Box.enabled? IRB::TOPLEVEL_BINDING = binding module IRB # :nodoc: @@ -103,6 +104,10 @@ def load_helper_methods_to_main ancestors = class< Date: Thu, 26 Mar 2026 23:30:05 +0000 Subject: [PATCH 2/7] feat: add reload command for ReloadableRequire --- lib/irb/command/reload.rb | 44 +++++++++++++++++++++++++++++++++++++ lib/irb/default_commands.rb | 2 ++ 2 files changed, 46 insertions(+) create mode 100644 lib/irb/command/reload.rb diff --git a/lib/irb/command/reload.rb b/lib/irb/command/reload.rb new file mode 100644 index 000000000..c368e03e7 --- /dev/null +++ b/lib/irb/command/reload.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module IRB + # :stopdoc: + + module Command + class Reload < Base + category "IRB" + description "Reload files that were loaded via require in IRB session." + + def execute(_arg) + unless reloadable_require_available? + warn "The reload command requires IRB.conf[:RELOADABLE_REQUIRE] = true and Ruby::Box (Ruby 4.0+) with RUBY_BOX=1 environment variable." + return + end + + files = IRB.conf[:__RELOADABLE_FILES__] + if files.empty? + puts "No files to reload. Use require to load files first." + return + end + + files.each { |path| reload_file(path) } + end + + private + + def reloadable_require_available? + IRB.conf[:RELOADABLE_REQUIRE] && defined?(Ruby::Box) && Ruby::Box.enabled? + end + + def reload_file(path) + load path + puts "Reloaded: #{path}" + rescue LoadError => e + warn "Failed to reload #{path}: #{e.message}" + rescue SyntaxError => e + warn "Syntax error in #{path}: #{e.message}" + end + end + end + + # :startdoc: +end diff --git a/lib/irb/default_commands.rb b/lib/irb/default_commands.rb index 9820a1f30..50641aa01 100644 --- a/lib/irb/default_commands.rb +++ b/lib/irb/default_commands.rb @@ -26,6 +26,7 @@ require_relative "command/measure" require_relative "command/next" require_relative "command/pushws" +require_relative "command/reload" require_relative "command/show_doc" require_relative "command/show_source" require_relative "command/step" @@ -252,6 +253,7 @@ def load_command(command) register(:cd, Command::CD) register(:copy, Command::Copy) + register(:reload, Command::Reload) end ExtendCommand = Command From 2545e10056bd28e901e4932def44fd425b5ec313 Mon Sep 17 00:00:00 2001 From: ahogappa Date: Thu, 26 Mar 2026 23:30:10 +0000 Subject: [PATCH 3/7] test: add tests for ReloadableRequire --- test/irb/test_reloadable_require.rb | 373 ++++++++++++++++++++++++++++ 1 file changed, 373 insertions(+) create mode 100644 test/irb/test_reloadable_require.rb diff --git a/test/irb/test_reloadable_require.rb b/test/irb/test_reloadable_require.rb new file mode 100644 index 000000000..6096dcafc --- /dev/null +++ b/test/irb/test_reloadable_require.rb @@ -0,0 +1,373 @@ +# frozen_string_literal: true + +require "tempfile" +require "fileutils" + +require_relative "helper" + +module TestIRB + class ReloadableRequireIntegrationTest < IntegrationTestCase + def setup + super + + omit "ReloadableRequire requires Ruby::Box" if !defined?(Ruby::Box) || !Ruby::Box.enabled? + + @envs["RUBY_BOX"] = "1" + + setup_lib_files + + write_rc <<~RUBY + IRB.conf[:RELOADABLE_REQUIRE] = true + $LOAD_PATH.unshift('#{@lib_dir}') + RUBY + end + + def teardown + super + FileUtils.rm_rf(@lib_dir) if @lib_dir + @pwd_files&.each { |f| File.delete(f) if File.exist?(f) } + end + + def test_require_enables_reload + write_ruby <<~'RUBY' + binding.irb + RUBY + + output = run_ruby_file do + type "require 'nested_a'" + type "NESTED_A_VALUE" + type "NESTED_B_VALUE" + type "reload" + type "exit!" + end + + assert_include output, "=> \"from_a\"" + assert_include output, "=> \"from_b\"" + assert_include output, "Reloaded: #{@nested_a_path}" + assert_include output, "Reloaded: #{@nested_b_path}" + end + + def test_require_relative_from_irb_prompt_enables_reload + write_ruby <<~'RUBY' + binding.irb + RUBY + + output = run_ruby_file do + type "require_relative 'require_relative_lib'" + type "REQUIRE_RELATIVE_LIB_VALUE" + type "REQUIRE_RELATIVE_DEP" + type "reload" + type "exit!" + end + + assert_include output, "=> 42" + assert_include output, "=> \"dep\"" + assert_include output, "Reloaded: #{@require_relative_lib_path}" + assert_include output, "Reloaded: #{@require_relative_dep_path}" + end + + def test_require_with_nested_require_relative_enables_reload + write_ruby <<~'RUBY' + binding.irb + RUBY + + output = run_ruby_file do + type "require '#{@relative_nested_a_path}'" + type "RELATIVE_NESTED_A" + type "RELATIVE_NESTED_B" + type "reload" + type "exit!" + end + + assert_include output, "=> \"from_a\"" + assert_include output, "=> \"from_b\"" + assert_include output, "Reloaded: #{@relative_nested_a_path}" + assert_include output, "Reloaded: #{@relative_nested_b_path}" + end + + def test_autoload_enables_reload + write_ruby <<~'RUBY' + binding.irb + RUBY + + output = run_ruby_file do + type "autoload :AutoloadMain, 'autoload_main'" + type "AutoloadMain::VALUE" + type "AUTOLOAD_DEP_VALUE" + type "reload" + type "exit!" + end + + assert_include output, "=> \"main\"" + assert_include output, "=> \"dependency\"" + assert_include output, "Reloaded: #{@autoload_main_path}" + assert_include output, "Reloaded: #{@autoload_dep_path}" + end + + def test_reload_without_any_loaded_files + write_ruby <<~'RUBY' + binding.irb + RUBY + + output = run_ruby_file do + type "reload" + type "exit!" + end + + assert_include output, "No files to reload" + end + + def test_reload_reflects_file_changes + write_ruby <<~'RUBY' + binding.irb + RUBY + + output = run_ruby_file do + type "require '#{@changeable_lib_path}'" + type "CHANGEABLE_VALUE" + type "File.write('#{@changeable_lib_path}', \"CHANGEABLE_VALUE = 'modified'\\n\")" + type "reload" + type "CHANGEABLE_VALUE" + type "exit!" + end + + assert_include output, "=> \"original\"" + assert_include output, "Reloaded: #{@changeable_lib_path}" + assert_include output, "=> \"modified\"" + end + + def test_reload_command_without_reloadable_require_enabled + write_rc <<~'RUBY' + IRB.conf[:RELOADABLE_REQUIRE] = false + RUBY + + write_ruby <<~'RUBY' + binding.irb + RUBY + + output = run_ruby_file do + type "reload" + type "exit!" + end + + assert_include output, "requires IRB.conf[:RELOADABLE_REQUIRE] = true" + end + + def test_require_updates_loaded_features + write_ruby <<~'RUBY' + binding.irb + RUBY + + output = run_ruby_file do + type "require 'nested_a'" + type "$LOADED_FEATURES.include?('#{@nested_a_path}')" + type "$LOADED_FEATURES.include?('#{@nested_b_path}')" + type "exit!" + end + + # Both files should be in $LOADED_FEATURES + assert_equal 2, output.scan("=> true").count + end + + def test_autoload_updates_loaded_features + write_ruby <<~'RUBY' + binding.irb + RUBY + + output = run_ruby_file do + type "autoload :AutoloadMain, 'autoload_main'" + type "AutoloadMain" + type "$LOADED_FEATURES.include?('#{@autoload_main_path}')" + type "$LOADED_FEATURES.include?('#{@autoload_dep_path}')" + type "exit!" + end + + # Both files should be in $LOADED_FEATURES + assert_equal 2, output.scan("=> true").count + end + + def test_reload_preserves_loaded_features + write_ruby <<~'RUBY' + binding.irb + RUBY + + output = run_ruby_file do + type "require 'nested_a'" + type "$LOADED_FEATURES.include?('#{@nested_a_path}')" + type "reload" + type "$LOADED_FEATURES.include?('#{@nested_a_path}')" + type "exit!" + end + + # Both checks should return true (before and after reload) + assert_equal 2, output.scan("=> true").count + end + + def test_require_does_not_modify_load_path + write_ruby <<~'RUBY' + binding.irb + RUBY + + output = run_ruby_file do + type "load_path_before = $LOAD_PATH.dup" + type "require 'nested_a'" + type "$LOAD_PATH == load_path_before" + type "exit!" + end + + assert_include output, "=> true" + end + + private + + def setup_lib_files + @lib_dir = Dir.mktmpdir + @pwd_files = [] + + # Nested require files (primary test files) + @nested_b_path = create_lib_file("nested_b.rb", "NESTED_B_VALUE = 'from_b'\n") + @nested_a_path = create_lib_file("nested_a.rb", "require 'nested_b'\nNESTED_A_VALUE = 'from_a'\n") + + # Nested require_relative files (for testing require that internally uses require_relative) + @relative_nested_b_path = create_lib_file("relative_nested_b.rb", "RELATIVE_NESTED_B = 'from_b'\n") + @relative_nested_a_path = create_lib_file( + "relative_nested_a.rb", + "require_relative 'relative_nested_b'\nRELATIVE_NESTED_A = 'from_a'\n" + ) + + # Files in Dir.pwd for require_relative from IRB prompt (with nested dependency) + @require_relative_dep_path = create_pwd_file("require_relative_dep.rb", "REQUIRE_RELATIVE_DEP = 'dep'\n") + @require_relative_lib_path = create_pwd_file( + "require_relative_lib.rb", + "require_relative 'require_relative_dep'\nREQUIRE_RELATIVE_LIB_VALUE = 42\n" + ) + + # Autoload files with nested require + @autoload_dep_path = create_lib_file("autoload_dep.rb", "AUTOLOAD_DEP_VALUE = 'dependency'\n") + @autoload_main_path = create_lib_file( + "autoload_main.rb", + "require 'autoload_dep'\nmodule AutoloadMain; VALUE = 'main'; end\n" + ) + + # Changeable file (for testing reload reflects changes) + @changeable_lib_path = create_lib_file("changeable.rb", "CHANGEABLE_VALUE = 'original'\n") + end + + def create_lib_file(name, content) + path = File.join(@lib_dir, name) + File.write(path, content) + File.realpath(path) + end + + def create_pwd_file(name, content) + path = File.join(Dir.pwd, name) + File.write(path, content) + @pwd_files << path + File.realpath(path) + end + end + + class ReloadCommandTest < TestCase + def setup + super + @tmpdir = Dir.mktmpdir + @valid_file = File.join(@tmpdir, "valid.rb") + File.write(@valid_file, "VALID = true\n") + @valid_file = File.realpath(@valid_file) + + require "irb" + require "irb/command/reload" + IRB.setup(__FILE__, argv: []) + @original_reloadable = IRB.conf[:RELOADABLE_REQUIRE] + @original_files = IRB.conf[:__RELOADABLE_FILES__] + IRB.conf[:RELOADABLE_REQUIRE] = true + IRB.conf[:__RELOADABLE_FILES__] = Set.new + end + + def teardown + super + FileUtils.rm_rf(@tmpdir) + IRB.conf[:RELOADABLE_REQUIRE] = @original_reloadable + IRB.conf[:__RELOADABLE_FILES__] = @original_files + end + + def test_reload_file_preserves_loaded_features_on_syntax_error + $LOADED_FEATURES << @valid_file + + File.write(@valid_file, "def broken(") + + cmd = IRB::Command::Reload.new(nil) + IRB.conf[:__RELOADABLE_FILES__] << @valid_file + cmd.execute(nil) + + assert_equal true, $LOADED_FEATURES.include?(@valid_file) + ensure + $LOADED_FEATURES.delete(@valid_file) + end + + def test_reload_file_preserves_loaded_features_on_load_error + missing_file = File.join(@tmpdir, "missing.rb") + $LOADED_FEATURES << missing_file + + cmd = IRB::Command::Reload.new(nil) + IRB.conf[:__RELOADABLE_FILES__] << missing_file + cmd.execute(nil) + + assert_equal true, $LOADED_FEATURES.include?(missing_file) + ensure + $LOADED_FEATURES.delete(missing_file) + end + end + + class ReloadableRequireMonkeyPatchTest < TestCase + def setup + super + omit "ReloadableRequire requires Ruby::Box" if !defined?(Ruby::Box) || !Ruby::Box.enabled? + require "irb/reloadable_require" + @saved_original_require = Ruby::Box.instance_method(:__irb_original_require__) + end + + def teardown + super + saved = @saved_original_require + if saved + Ruby::Box.define_method(:__irb_original_require__, saved) + end + end + + def test_original_require_alias_preserved_on_double_load + IRB::ReloadableRequire.apply_autoload_hook + + # Double-loading the file should not overwrite __irb_original_require__ + # with the already-patched require method. + load File.expand_path("../../lib/irb/reloadable_require.rb", __dir__) + + assert_equal @saved_original_require, Ruby::Box.instance_method(:__irb_original_require__) + end + end + + class ReloadableRequireDisabledTest < IntegrationTestCase + def setup + super + + omit "This test is for Ruby::Box disabled environment" if defined?(Ruby::Box) && Ruby::Box.enabled? + end + + def test_reload_command_shows_error_without_ruby_box + write_rc <<~'RUBY' + IRB.conf[:RELOADABLE_REQUIRE] = true + RUBY + + write_ruby <<~'RUBY' + binding.irb + RUBY + + output = run_ruby_file do + type "reload" + type "exit!" + end + + assert_include output, "requires IRB.conf[:RELOADABLE_REQUIRE] = true and Ruby::Box" + end + end +end From cdbbb67b65493aac9749e022944ba10622ace05e Mon Sep 17 00:00:00 2001 From: ahogappa Date: Thu, 26 Mar 2026 23:30:14 +0000 Subject: [PATCH 4/7] fix: add require 'set' for Ruby < 3.2 compatibility --- lib/irb/init.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/irb/init.rb b/lib/irb/init.rb index 365f445da..892dfa240 100644 --- a/lib/irb/init.rb +++ b/lib/irb/init.rb @@ -4,6 +4,8 @@ # by Keiju ISHITSUKA(keiju@ruby-lang.org) # +require 'set' + module IRB # :nodoc: @CONF = {} @INITIALIZED = false @@ -197,6 +199,10 @@ def IRB.init_config(ap_path) } @CONF[:COPY_COMMAND] = ENV.fetch("IRB_COPY_COMMAND", nil) + + @CONF[:RELOADABLE_REQUIRE] = false + @CONF[:__RELOADABLE_FILES__] = Set.new + @CONF[:__AUTOLOAD_FILES__] = Set.new end def IRB.set_measure_callback(type = nil, arg = nil, &block) From 3fcbb5c1394d770a52fd9a96f1d3d58d88ea3ed6 Mon Sep 17 00:00:00 2001 From: ahogappa Date: Sun, 12 Apr 2026 11:47:03 +0000 Subject: [PATCH 5/7] refactor: rename reload command to box_reload and mark as experimental --- lib/irb/command/reload.rb | 33 +++++++++++++++++++++++++---- lib/irb/default_commands.rb | 2 +- test/irb/test_reloadable_require.rb | 18 ++++++++-------- 3 files changed, 39 insertions(+), 14 deletions(-) diff --git a/lib/irb/command/reload.rb b/lib/irb/command/reload.rb index c368e03e7..9b0193282 100644 --- a/lib/irb/command/reload.rb +++ b/lib/irb/command/reload.rb @@ -4,17 +4,42 @@ module IRB # :stopdoc: module Command - class Reload < Base + class BoxReload < Base category "IRB" - description "Reload files that were loaded via require in IRB session." + description "[Experimental] Reload files that were loaded via require in IRB session (requires Ruby::Box)." + + help_message <<~HELP + Usage: box_reload + + Reloads all Ruby files that were loaded via `require` or `require_relative` + during the current IRB session. This allows you to pick up changes made to + source files without restarting IRB. + + Setup: + 1. Start Ruby with RUBY_BOX=1 environment variable + 2. Set IRB.conf[:RELOADABLE_REQUIRE] = true in your .irbrc + + Example: + # In .irbrc: + IRB.conf[:RELOADABLE_REQUIRE] = true + + # In IRB session: + require 'my_lib' # loaded and tracked + # ... edit my_lib.rb ... + box_reload # reloads the file + + Note: This feature is experimental and requires Ruby::Box (Ruby 4.0+). + Native extensions (.so/.bundle) cannot be reloaded. + HELP def execute(_arg) unless reloadable_require_available? - warn "The reload command requires IRB.conf[:RELOADABLE_REQUIRE] = true and Ruby::Box (Ruby 4.0+) with RUBY_BOX=1 environment variable." + warn "box_reload requires IRB.conf[:RELOADABLE_REQUIRE] = true and Ruby::Box (Ruby 4.0+) with RUBY_BOX=1 environment variable." return end - files = IRB.conf[:__RELOADABLE_FILES__] + ReloadableRequire.collect_autoloaded_files + files = ReloadableRequire.reloadable_files if files.empty? puts "No files to reload. Use require to load files first." return diff --git a/lib/irb/default_commands.rb b/lib/irb/default_commands.rb index 50641aa01..8138f8232 100644 --- a/lib/irb/default_commands.rb +++ b/lib/irb/default_commands.rb @@ -253,7 +253,7 @@ def load_command(command) register(:cd, Command::CD) register(:copy, Command::Copy) - register(:reload, Command::Reload) + register(:box_reload, Command::BoxReload) end ExtendCommand = Command diff --git a/test/irb/test_reloadable_require.rb b/test/irb/test_reloadable_require.rb index 6096dcafc..8dc7fa7f7 100644 --- a/test/irb/test_reloadable_require.rb +++ b/test/irb/test_reloadable_require.rb @@ -37,7 +37,7 @@ def test_require_enables_reload type "require 'nested_a'" type "NESTED_A_VALUE" type "NESTED_B_VALUE" - type "reload" + type "box_reload" type "exit!" end @@ -56,7 +56,7 @@ def test_require_relative_from_irb_prompt_enables_reload type "require_relative 'require_relative_lib'" type "REQUIRE_RELATIVE_LIB_VALUE" type "REQUIRE_RELATIVE_DEP" - type "reload" + type "box_reload" type "exit!" end @@ -75,7 +75,7 @@ def test_require_with_nested_require_relative_enables_reload type "require '#{@relative_nested_a_path}'" type "RELATIVE_NESTED_A" type "RELATIVE_NESTED_B" - type "reload" + type "box_reload" type "exit!" end @@ -94,7 +94,7 @@ def test_autoload_enables_reload type "autoload :AutoloadMain, 'autoload_main'" type "AutoloadMain::VALUE" type "AUTOLOAD_DEP_VALUE" - type "reload" + type "box_reload" type "exit!" end @@ -110,7 +110,7 @@ def test_reload_without_any_loaded_files RUBY output = run_ruby_file do - type "reload" + type "box_reload" type "exit!" end @@ -126,7 +126,7 @@ def test_reload_reflects_file_changes type "require '#{@changeable_lib_path}'" type "CHANGEABLE_VALUE" type "File.write('#{@changeable_lib_path}', \"CHANGEABLE_VALUE = 'modified'\\n\")" - type "reload" + type "box_reload" type "CHANGEABLE_VALUE" type "exit!" end @@ -146,7 +146,7 @@ def test_reload_command_without_reloadable_require_enabled RUBY output = run_ruby_file do - type "reload" + type "box_reload" type "exit!" end @@ -194,7 +194,7 @@ def test_reload_preserves_loaded_features output = run_ruby_file do type "require 'nested_a'" type "$LOADED_FEATURES.include?('#{@nested_a_path}')" - type "reload" + type "box_reload" type "$LOADED_FEATURES.include?('#{@nested_a_path}')" type "exit!" end @@ -363,7 +363,7 @@ def test_reload_command_shows_error_without_ruby_box RUBY output = run_ruby_file do - type "reload" + type "box_reload" type "exit!" end From b1e5c442d4e15e3adcb4ca2fdd6f7b12a6f1eb1d Mon Sep 17 00:00:00 2001 From: ahogappa Date: Sun, 12 Apr 2026 11:47:34 +0000 Subject: [PATCH 6/7] refactor: move reloadable require internal state out of IRB.conf --- lib/irb/init.rb | 4 -- lib/irb/reloadable_require.rb | 60 ++++++++++++++------------ test/irb/test_reloadable_require.rb | 66 +++++++++++++---------------- 3 files changed, 61 insertions(+), 69 deletions(-) diff --git a/lib/irb/init.rb b/lib/irb/init.rb index 892dfa240..750651b49 100644 --- a/lib/irb/init.rb +++ b/lib/irb/init.rb @@ -4,8 +4,6 @@ # by Keiju ISHITSUKA(keiju@ruby-lang.org) # -require 'set' - module IRB # :nodoc: @CONF = {} @INITIALIZED = false @@ -201,8 +199,6 @@ def IRB.init_config(ap_path) @CONF[:COPY_COMMAND] = ENV.fetch("IRB_COPY_COMMAND", nil) @CONF[:RELOADABLE_REQUIRE] = false - @CONF[:__RELOADABLE_FILES__] = Set.new - @CONF[:__AUTOLOAD_FILES__] = Set.new end def IRB.set_measure_callback(type = nil, arg = nil, &block) diff --git a/lib/irb/reloadable_require.rb b/lib/irb/reloadable_require.rb index 798524878..ac86a2665 100644 --- a/lib/irb/reloadable_require.rb +++ b/lib/irb/reloadable_require.rb @@ -4,6 +4,8 @@ raise "ReloadableRequire requires Ruby::Box to be enabled" end +require 'set' + module IRB # Provides reload-aware require functionality for IRB. # @@ -15,28 +17,6 @@ module IRB # # This feature requires Ruby::Box (Ruby 4.0+). - class << self - def track_and_load_files(source, current_box) - before = source.dup - result = yield - new_files = source - before - - return result if new_files.empty? - - ruby_files, native_extensions = new_files.partition { |path| path.end_with?('.rb') } - - native_extensions.each { |path| current_box.require(path) } - - IRB.conf[:__RELOADABLE_FILES__].merge(ruby_files) - - main_loaded_features = current_box.eval('$LOADED_FEATURES') - main_loaded_features.concat(ruby_files - main_loaded_features) - ruby_files.each { |path| current_box.load(path) } - - result - end - end - unless Ruby::Box.method_defined?(:__irb_original_require__) Ruby::Box.class_eval do alias_method :__irb_original_require__, :require @@ -45,14 +25,13 @@ def track_and_load_files(source, current_box) end Ruby::Box.class_eval do - def __irb_reloadable_require__(feature) - unless IRB.conf[:__AUTOLOAD_FILES__].include?(feature) + unless IRB::ReloadableRequire.autoload_files.include?(feature) return __irb_original_require__(feature) end - IRB.conf[:__AUTOLOAD_FILES__].delete(feature) - IRB.track_and_load_files($LOADED_FEATURES, Ruby::Box.main) { __irb_original_require__(feature) } + IRB::ReloadableRequire.autoload_files.delete(feature) + IRB::ReloadableRequire.track_and_load_files($LOADED_FEATURES, Ruby::Box.main) { __irb_original_require__(feature) } end def __irb_reloadable_require_relative__(feature) @@ -61,7 +40,12 @@ def __irb_reloadable_require_relative__(feature) end module ReloadableRequire + @reloadable_files = Set.new + @autoload_files = Set.new + class << self + attr_reader :reloadable_files, :autoload_files + def extended(base) apply_autoload_hook end @@ -72,6 +56,26 @@ def apply_autoload_hook alias_method :require_relative, :__irb_reloadable_require_relative__ end end + + def track_and_load_files(source, current_box) + before = source.dup + result = yield + new_files = source - before + + return result if new_files.empty? + + ruby_files, native_extensions = new_files.partition { |path| path.end_with?('.rb') } + + native_extensions.each { |path| current_box.require(path) } + + @reloadable_files.merge(ruby_files) + + main_loaded_features = current_box.eval('$LOADED_FEATURES') + main_loaded_features.concat(ruby_files - main_loaded_features) + ruby_files.each { |path| current_box.load(path) } + + result + end end private @@ -81,7 +85,7 @@ def reloadable_require_internal(feature, caller_box) box.eval("$LOAD_PATH.concat(#{caller_box.eval('$LOAD_PATH')})") box.eval("$LOADED_FEATURES.concat(#{caller_box.eval('$LOADED_FEATURES')})") - IRB.track_and_load_files(box.eval('$LOADED_FEATURES'), caller_box) { box.__irb_original_require__(feature) } + ReloadableRequire.track_and_load_files(box.eval('$LOADED_FEATURES'), caller_box) { box.__irb_original_require__(feature) } end def require(feature) @@ -108,7 +112,7 @@ def require_relative(feature) end def autoload(const, feature) - IRB.conf[:__AUTOLOAD_FILES__] << feature + ReloadableRequire.autoload_files << feature Ruby::Box.main.eval("Kernel.autoload(:#{const}, #{feature.dump})") end end diff --git a/test/irb/test_reloadable_require.rb b/test/irb/test_reloadable_require.rb index 8dc7fa7f7..420e58203 100644 --- a/test/irb/test_reloadable_require.rb +++ b/test/irb/test_reloadable_require.rb @@ -203,6 +203,20 @@ def test_reload_preserves_loaded_features assert_equal 2, output.scan("=> true").count end + def test_require_native_extension + write_ruby <<~'RUBY' + binding.irb + RUBY + + output = run_ruby_file do + type "require 'etc'" + type "Etc.getlogin" + type "exit!" + end + + assert_include output, "=> true" + end + def test_require_does_not_modify_load_path write_ruby <<~'RUBY' binding.irb @@ -267,9 +281,11 @@ def create_pwd_file(name, content) end end - class ReloadCommandTest < TestCase + class BoxReloadCommandTest < TestCase def setup super + omit "BoxReload requires Ruby::Box" if !defined?(Ruby::Box) || !Ruby::Box.enabled? + @tmpdir = Dir.mktmpdir @valid_file = File.join(@tmpdir, "valid.rb") File.write(@valid_file, "VALID = true\n") @@ -279,16 +295,19 @@ def setup require "irb/command/reload" IRB.setup(__FILE__, argv: []) @original_reloadable = IRB.conf[:RELOADABLE_REQUIRE] - @original_files = IRB.conf[:__RELOADABLE_FILES__] IRB.conf[:RELOADABLE_REQUIRE] = true - IRB.conf[:__RELOADABLE_FILES__] = Set.new + + @saved_files = IRB::ReloadableRequire.reloadable_files.dup + IRB::ReloadableRequire.reloadable_files.clear end def teardown super - FileUtils.rm_rf(@tmpdir) - IRB.conf[:RELOADABLE_REQUIRE] = @original_reloadable - IRB.conf[:__RELOADABLE_FILES__] = @original_files + FileUtils.rm_rf(@tmpdir) if @tmpdir + if defined?(IRB::ReloadableRequire) && @saved_files + IRB.conf[:RELOADABLE_REQUIRE] = @original_reloadable + IRB::ReloadableRequire.reloadable_files.replace(@saved_files) + end end def test_reload_file_preserves_loaded_features_on_syntax_error @@ -296,8 +315,8 @@ def test_reload_file_preserves_loaded_features_on_syntax_error File.write(@valid_file, "def broken(") - cmd = IRB::Command::Reload.new(nil) - IRB.conf[:__RELOADABLE_FILES__] << @valid_file + cmd = IRB::Command::BoxReload.new(nil) + IRB::ReloadableRequire.reloadable_files << @valid_file cmd.execute(nil) assert_equal true, $LOADED_FEATURES.include?(@valid_file) @@ -309,8 +328,8 @@ def test_reload_file_preserves_loaded_features_on_load_error missing_file = File.join(@tmpdir, "missing.rb") $LOADED_FEATURES << missing_file - cmd = IRB::Command::Reload.new(nil) - IRB.conf[:__RELOADABLE_FILES__] << missing_file + cmd = IRB::Command::BoxReload.new(nil) + IRB::ReloadableRequire.reloadable_files << missing_file cmd.execute(nil) assert_equal true, $LOADED_FEATURES.include?(missing_file) @@ -319,33 +338,6 @@ def test_reload_file_preserves_loaded_features_on_load_error end end - class ReloadableRequireMonkeyPatchTest < TestCase - def setup - super - omit "ReloadableRequire requires Ruby::Box" if !defined?(Ruby::Box) || !Ruby::Box.enabled? - require "irb/reloadable_require" - @saved_original_require = Ruby::Box.instance_method(:__irb_original_require__) - end - - def teardown - super - saved = @saved_original_require - if saved - Ruby::Box.define_method(:__irb_original_require__, saved) - end - end - - def test_original_require_alias_preserved_on_double_load - IRB::ReloadableRequire.apply_autoload_hook - - # Double-loading the file should not overwrite __irb_original_require__ - # with the already-patched require method. - load File.expand_path("../../lib/irb/reloadable_require.rb", __dir__) - - assert_equal @saved_original_require, Ruby::Box.instance_method(:__irb_original_require__) - end - end - class ReloadableRequireDisabledTest < IntegrationTestCase def setup super From 798a78cfc03aa4bf498ecbab809ae75a582063cb Mon Sep 17 00:00:00 2001 From: ahogappa Date: Sun, 12 Apr 2026 11:47:41 +0000 Subject: [PATCH 7/7] refactor: stop modifying Ruby::Box#require and use box.require for file detection --- lib/irb/reloadable_require.rb | 68 +++++++++++------------------------ 1 file changed, 21 insertions(+), 47 deletions(-) diff --git a/lib/irb/reloadable_require.rb b/lib/irb/reloadable_require.rb index ac86a2665..a40a40477 100644 --- a/lib/irb/reloadable_require.rb +++ b/lib/irb/reloadable_require.rb @@ -9,53 +9,18 @@ module IRB # Provides reload-aware require functionality for IRB. # + # This feature is experimental and requires Ruby::Box (Ruby 4.0+). + # # Limitations: # - Native extensions cannot be reloaded (load doesn't support them) - # - Files loaded via box.require are not tracked # - Constant redefinition warnings will appear on reload (uses load internally) - # - Context mode 5 (running IRB inside a Ruby::Box) is not supported - # - # This feature requires Ruby::Box (Ruby 4.0+). - - unless Ruby::Box.method_defined?(:__irb_original_require__) - Ruby::Box.class_eval do - alias_method :__irb_original_require__, :require - alias_method :__irb_original_require_relative__, :require_relative - end - end - - Ruby::Box.class_eval do - def __irb_reloadable_require__(feature) - unless IRB::ReloadableRequire.autoload_files.include?(feature) - return __irb_original_require__(feature) - end - - IRB::ReloadableRequire.autoload_files.delete(feature) - IRB::ReloadableRequire.track_and_load_files($LOADED_FEATURES, Ruby::Box.main) { __irb_original_require__(feature) } - end - - def __irb_reloadable_require_relative__(feature) - __irb_original_require_relative__(feature) - end - end module ReloadableRequire @reloadable_files = Set.new - @autoload_files = Set.new + @autoload_features = {} class << self - attr_reader :reloadable_files, :autoload_files - - def extended(base) - apply_autoload_hook - end - - def apply_autoload_hook - Ruby::Box.class_eval do - alias_method :require, :__irb_reloadable_require__ - alias_method :require_relative, :__irb_reloadable_require_relative__ - end - end + attr_reader :reloadable_files, :autoload_features def track_and_load_files(source, current_box) before = source.dup @@ -76,6 +41,15 @@ def track_and_load_files(source, current_box) result end + + def collect_autoloaded_files + @autoload_features.each_value do |feature| + resolved = $LOAD_PATH.resolve_feature_path(feature) rescue nil + next unless resolved && resolved.first == :rb + path = resolved[1] + @reloadable_files << path if $LOADED_FEATURES.include?(path) + end + end end private @@ -85,18 +59,16 @@ def reloadable_require_internal(feature, caller_box) box.eval("$LOAD_PATH.concat(#{caller_box.eval('$LOAD_PATH')})") box.eval("$LOADED_FEATURES.concat(#{caller_box.eval('$LOADED_FEATURES')})") - ReloadableRequire.track_and_load_files(box.eval('$LOADED_FEATURES'), caller_box) { box.__irb_original_require__(feature) } + ReloadableRequire.track_and_load_files(box.eval('$LOADED_FEATURES'), caller_box) { box.require(feature) } end def require(feature) caller_loc = caller_locations(1, 1).first - current_box = Ruby::Box.main - return current_box.__irb_original_require__(feature) unless caller_loc.path.end_with?("(irb)") - - resolved = current_box.eval("$LOAD_PATH.resolve_feature_path(#{feature.dump})") - return current_box.__irb_original_require__(feature) unless resolved&.first == :rb + return super unless caller_loc.path.end_with?("(irb)") - reloadable_require_internal(feature, current_box) + reloadable_require_internal(feature, Ruby::Box.main) + rescue LoadError + super end def require_relative(feature) @@ -109,10 +81,12 @@ def require_relative(feature) end reloadable_require_internal(File.expand_path(feature, Dir.pwd), current_box) + rescue LoadError + super end def autoload(const, feature) - ReloadableRequire.autoload_files << feature + ReloadableRequire.autoload_features[const.to_s] = feature Ruby::Box.main.eval("Kernel.autoload(:#{const}, #{feature.dump})") end end