diff --git a/lib/irb/command/reload.rb b/lib/irb/command/reload.rb new file mode 100644 index 000000000..cd46ad411 --- /dev/null +++ b/lib/irb/command/reload.rb @@ -0,0 +1,46 @@ +# 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) + $LOADED_FEATURES.delete(path) + load path + $LOADED_FEATURES << 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 diff --git a/lib/irb/init.rb b/lib/irb/init.rb index 720c4fec4..8e4b4381b 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 @@ -196,6 +198,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) diff --git a/lib/irb/reloadable_require.rb b/lib/irb/reloadable_require.rb new file mode 100644 index 000000000..cfb018c35 --- /dev/null +++ b/lib/irb/reloadable_require.rb @@ -0,0 +1,136 @@ +# 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 + + Ruby::Box.class_eval do + alias_method :__irb_original_require__, :require + alias_method :__irb_original_require_relative__, :require_relative + + 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(absolute_path, caller_box) + return false if caller_box.eval('$LOADED_FEATURES').include?(absolute_path) + + box = Ruby::Box.new + load_path = caller_box.eval('$LOAD_PATH') + # Copy $LOAD_PATH to the box so it can resolve dependencies. + box.eval("$LOAD_PATH.concat(#{load_path})") + + IRB.track_and_load_files(box.eval('$LOADED_FEATURES'), caller_box) { box.__irb_original_require__(absolute_path) } + end + + def require(feature) + caller_loc = caller_locations(1, 1).first + current_box = Ruby::Box.main + resolved = current_box.eval("$LOAD_PATH.resolve_feature_path(#{feature.dump})") + + # Fallback for calls outside IRB prompt + if caller_loc.path != "(irb)" || !resolved || resolved[0] != :rb + return current_box.require(feature) + end + + reloadable_require_internal(resolved[1], current_box) + end + + def require_relative(feature) + caller_loc = caller_locations(1, 1).first + current_box = Ruby::Box.main + + # Fallback for calls outside IRB prompt + if caller_loc.path != "(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 + + absolute_path = resolve_require_relative_path(feature) + + if !absolute_path.end_with?('.rb') || !File.exist?(absolute_path) + file_path = File.join(Dir.pwd, "(irb)") + return current_box.eval("eval('Kernel.require_relative(#{feature.dump})', nil, #{file_path.dump}, 1)") + end + + reloadable_require_internal(absolute_path, current_box) + end + + def resolve_require_relative_path(feature) + absolute_path = File.expand_path(feature, Dir.pwd) + return absolute_path unless File.extname(absolute_path).empty? + + dlext = RbConfig::CONFIG['DLEXT'] + ['.rb', ".#{dlext}"].each do |ext| + candidate = absolute_path + ext + return candidate if File.exist?(candidate) + end + + absolute_path + 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< \"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 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