diff --git a/lib/irb/command/reload.rb b/lib/irb/command/reload.rb new file mode 100644 index 000000000..9b0193282 --- /dev/null +++ b/lib/irb/command/reload.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module IRB + # :stopdoc: + + module Command + class BoxReload < Base + category "IRB" + 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 "box_reload requires IRB.conf[:RELOADABLE_REQUIRE] = true and Ruby::Box (Ruby 4.0+) with RUBY_BOX=1 environment variable." + return + end + + ReloadableRequire.collect_autoloaded_files + files = ReloadableRequire.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..8138f8232 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(:box_reload, Command::BoxReload) end ExtendCommand = Command diff --git a/lib/irb/init.rb b/lib/irb/init.rb index 365f445da..750651b49 100644 --- a/lib/irb/init.rb +++ b/lib/irb/init.rb @@ -197,6 +197,8 @@ def IRB.init_config(ap_path) } @CONF[:COPY_COMMAND] = ENV.fetch("IRB_COPY_COMMAND", nil) + + @CONF[:RELOADABLE_REQUIRE] = false 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 new file mode 100644 index 000000000..a40a40477 --- /dev/null +++ b/lib/irb/reloadable_require.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +if !defined?(Ruby::Box) || !Ruby::Box.enabled? + raise "ReloadableRequire requires Ruby::Box to be enabled" +end + +require 'set' + +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) + # - Constant redefinition warnings will appear on reload (uses load internally) + + module ReloadableRequire + @reloadable_files = Set.new + @autoload_features = {} + + class << self + attr_reader :reloadable_files, :autoload_features + + 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 + + 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 + + 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')})") + + 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 + return super unless caller_loc.path.end_with?("(irb)") + + reloadable_require_internal(feature, Ruby::Box.main) + rescue LoadError + super + 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) + rescue LoadError + super + end + + def autoload(const, feature) + ReloadableRequire.autoload_features[const.to_s] = 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< \"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 "box_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 "box_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 "box_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 "box_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 "box_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 "box_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 "box_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_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 + 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 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") + @valid_file = File.realpath(@valid_file) + + require "irb" + require "irb/command/reload" + IRB.setup(__FILE__, argv: []) + @original_reloadable = IRB.conf[:RELOADABLE_REQUIRE] + IRB.conf[:RELOADABLE_REQUIRE] = true + + @saved_files = IRB::ReloadableRequire.reloadable_files.dup + IRB::ReloadableRequire.reloadable_files.clear + end + + def teardown + super + 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 + $LOADED_FEATURES << @valid_file + + File.write(@valid_file, "def broken(") + + cmd = IRB::Command::BoxReload.new(nil) + IRB::ReloadableRequire.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::BoxReload.new(nil) + IRB::ReloadableRequire.reloadable_files << missing_file + cmd.execute(nil) + + assert_equal true, $LOADED_FEATURES.include?(missing_file) + ensure + $LOADED_FEATURES.delete(missing_file) + 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 "box_reload" + type "exit!" + end + + assert_include output, "requires IRB.conf[:RELOADABLE_REQUIRE] = true and Ruby::Box" + end + end +end