|
| 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 |
0 commit comments