├── .gitignore ├── lib ├── im │ ├── version.rb │ ├── internal.rb │ ├── gem_inflector.rb │ ├── error.rb │ ├── kernel.rb │ ├── const_path.rb │ ├── inflector.rb │ ├── gem_loader.rb │ ├── module_const_added.rb │ ├── loader │ │ ├── helpers.rb │ │ ├── callbacks.rb │ │ ├── eager_load.rb │ │ └── config.rb │ ├── explicit_namespace.rb │ └── registry.rb └── im.rb ├── bin └── test ├── test ├── support │ ├── remove_const.rb │ ├── on_teardown.rb │ ├── test_macro.rb │ ├── delete_loaded_feature.rb │ └── loader_test.rb ├── test_helper.rb └── lib │ ├── im │ ├── test_all_dirs.rb │ ├── test_shared_namespaces.rb │ ├── test_shadowed_files.rb │ ├── test_import.rb │ ├── test_multiple_loaders.rb │ ├── test_on_setup.rb │ ├── test_nested_root_directories.rb │ ├── test_ancestors.rb │ ├── test_utf8.rb │ ├── test_private_constants.rb │ ├── test_callbacks.rb │ ├── test_unregister.rb │ ├── test_push_dir.rb │ ├── test_marshal.rb │ ├── test_top_level.rb │ ├── test_sti_old_school_workaround.rb │ ├── test_unloadable_cpath.rb │ ├── test_const_added.rb │ ├── test_exceptions.rb │ ├── test_load_file.rb │ ├── test_conflicting_directory.rb │ ├── test_autovivification.rb │ ├── test_collapse.rb │ ├── test_unload.rb │ ├── test_on_unload.rb │ ├── test_ignore.rb │ ├── test_on_load.rb │ ├── test_eager_load_namespace.rb │ ├── test_logging.rb │ ├── test_reloading.rb │ ├── test_explicit_namespace.rb │ ├── test_require_interaction.rb │ ├── test_for_gem.rb │ ├── test_eager_load_dir.rb │ ├── test_eager_load.rb │ └── test_ruby_compatibility.rb │ ├── test_inflector.rb │ └── test_gem_inflector.rb ├── Gemfile ├── Rakefile ├── CHANGELOG.md ├── .github └── workflows │ ├── ci.yml │ └── keep-an-eye-on-ruby-head.yml ├── im.gemspec ├── MIT-LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | Gemfile.lock 2 | test/tmp 3 | *.gem 4 | -------------------------------------------------------------------------------- /lib/im/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Im 4 | VERSION = "0.2.2" 5 | end 6 | -------------------------------------------------------------------------------- /bin/test: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [[ -z $1 ]]; then 4 | bundle exec rake 5 | else 6 | bundle exec rake TEST="$1" 7 | fi 8 | -------------------------------------------------------------------------------- /test/support/remove_const.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RemoveConst 4 | def remove_const(cname, from: Object) 5 | from.__send__(:remove_const, cname) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/support/on_teardown.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module OnTeardown 4 | def on_teardown 5 | define_singleton_method(:teardown) do 6 | yield 7 | super() 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/support/test_macro.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TestMacro 4 | def test(description, &block) 5 | method_name = "test_#{description}".gsub(/\W/, "_") 6 | define_method(method_name, &block) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec 6 | 7 | gem "rake" 8 | gem "minitest" 9 | gem "minitest-focus" 10 | gem "minitest-proveit" 11 | gem "minitest-reporters" 12 | gem "warning" 13 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rake/testtask' 4 | 5 | task :default => :test 6 | 7 | Rake::TestTask.new do |t| 8 | t.test_files = Dir.glob('test/lib/**/test_*.rb') 9 | t.libs << "test" 10 | t.warning = true 11 | end 12 | -------------------------------------------------------------------------------- /test/support/delete_loaded_feature.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DeleteLoadedFeature 4 | def delete_loaded_feature(path) 5 | $LOADED_FEATURES.delete_if do |abspath| 6 | abspath.end_with?(path) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/im/internal.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This is a private module. 4 | module Im::Internal 5 | def internal(method_name) 6 | private method_name 7 | 8 | mangled = "__#{method_name}" 9 | alias_method mangled, method_name 10 | public mangled 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## 0.2.2 (2023-1-28 4 | * Pass `parent` to `relative_cpath` in `set_autoloads_in_dir`. 5 | 6 | ## 0.2.1 (2023-1-28 7 | * Fix absolute cpath reference in `autoload_subdir`. 8 | 9 | ## 0.2.0 (2023-1-28) 10 | * Rewrite as fork of Zeitwerk. All previous releases removed from the git history. 11 | -------------------------------------------------------------------------------- /lib/im/gem_inflector.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Im 4 | class GemInflector < Inflector 5 | # @sig (String) -> void 6 | def initialize(root_file) 7 | namespace = File.basename(root_file, ".rb") 8 | lib_dir = File.dirname(root_file) 9 | @version_file = File.join(lib_dir, namespace, "version.rb") 10 | end 11 | 12 | # @sig (String, String) -> String 13 | def camelize(basename, abspath) 14 | abspath == @version_file ? "VERSION" : super 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/im/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Im 4 | class Error < StandardError 5 | end 6 | 7 | class ReloadingDisabledError < Error 8 | def initialize 9 | super("can't reload, please call loader.enable_reloading before setup") 10 | end 11 | end 12 | 13 | class NameError < ::NameError 14 | end 15 | 16 | class SetupRequired < Error 17 | def initialize 18 | super("please, finish your configuration and call Im::Loader#setup once all is ready") 19 | end 20 | end 21 | 22 | class InvalidModuleName < Error 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: "CI" 2 | on: 3 | push: 4 | pull_request: 5 | branches: 6 | - "main" 7 | jobs: 8 | test: 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | os: 13 | - "ubuntu-latest" 14 | - "macos-latest" 15 | - "windows-latest" 16 | ruby-version: 17 | - "3.2" 18 | - "head" 19 | runs-on: ${{ matrix.os }} 20 | steps: 21 | - uses: "actions/checkout@v3" 22 | - uses: "ruby/setup-ruby@v1" 23 | with: 24 | ruby-version: ${{ matrix.ruby-version }} 25 | bundler-cache: true 26 | - run: "bundle exec rake" 27 | -------------------------------------------------------------------------------- /.github/workflows/keep-an-eye-on-ruby-head.yml: -------------------------------------------------------------------------------- 1 | name: "Keep an eye on Ruby HEAD" 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | - cron: "11 9 * * *" 6 | jobs: 7 | test: 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | os: 12 | - "ubuntu-latest" 13 | - "macos-latest" 14 | - "windows-latest" 15 | ruby-version: 16 | - "head" 17 | runs-on: ${{ matrix.os }} 18 | steps: 19 | - uses: "actions/checkout@v3" 20 | - uses: "ruby/setup-ruby@v1" 21 | with: 22 | ruby-version: ${{ matrix.ruby-version }} 23 | bundler-cache: true 24 | - run: "bundle exec rake" 25 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "minitest/autorun" 4 | require "minitest/focus" 5 | require "minitest/proveit" 6 | 7 | require "minitest/reporters" 8 | Minitest::Reporters.use!(Minitest::Reporters::DefaultReporter.new) 9 | 10 | require "warning" 11 | 12 | require_relative "support/test_macro" 13 | require_relative "support/delete_loaded_feature" 14 | require_relative "support/loader_test" 15 | require_relative "support/remove_const" 16 | require_relative "support/on_teardown" 17 | 18 | require "im" 19 | 20 | Minitest::Test.class_eval do 21 | extend TestMacro 22 | include DeleteLoadedFeature 23 | include RemoveConst 24 | include OnTeardown 25 | 26 | prove_it! 27 | end 28 | -------------------------------------------------------------------------------- /lib/im.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Im 4 | require_relative "im/const_path" 5 | require_relative "im/internal" 6 | require_relative "im/loader" 7 | require_relative "im/gem_loader" 8 | require_relative "im/registry" 9 | require_relative "im/explicit_namespace" 10 | require_relative "im/module_const_added" 11 | require_relative "im/inflector" 12 | require_relative "im/gem_inflector" 13 | require_relative "im/kernel" 14 | require_relative "im/error" 15 | require_relative "im/version" 16 | 17 | extend Im::ConstPath 18 | 19 | # @sig (String) -> Im::Loader? 20 | def import(path) 21 | _, feature_path = $:.resolve_feature_path(path) 22 | Registry.loader_for(feature_path) if feature_path 23 | end 24 | 25 | extend self 26 | end 27 | -------------------------------------------------------------------------------- /test/lib/im/test_all_dirs.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class TestAllDirs < LoaderTest 6 | test "returns an empty array if no loaders are instantiated" do 7 | assert_empty Im::Loader.all_dirs 8 | end 9 | 10 | test "returns an empty array if there are loaders but they have no root dirs" do 11 | 2.times { new_loader } 12 | assert_empty Im::Loader.all_dirs 13 | end 14 | 15 | test "returns the root directories of the registered loaders" do 16 | files = [ 17 | ["loaderA/a.rb", "A = true"], 18 | ["loaderB/b.rb", "B = true"] 19 | ] 20 | with_files(files) do 21 | new_loader(dirs: "loaderA") 22 | new_loader(dirs: "loaderB") 23 | 24 | assert_equal ["#{Dir.pwd}/loaderA", "#{Dir.pwd}/loaderB"].to_set, Im::Loader.all_dirs 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/lib/im/test_shared_namespaces.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class TestSharedNamespaces < LoaderTest 6 | test "autoloads from a shared implicit namespace" do 7 | mod = Module.new 8 | loader::M = mod 9 | 10 | files = [["m/x.rb", "M::X = true"]] 11 | with_setup(files) do 12 | assert loader::M::X 13 | loader.reload 14 | assert_same mod, loader::M 15 | assert loader::M::X 16 | end 17 | end 18 | 19 | test "autoloads from a shared explicit namespace" do 20 | mod = Module.new 21 | loader::M = mod 22 | 23 | files = [ 24 | ["m.rb", "class M; end"], 25 | ["m/x.rb", "M::X = true"] 26 | ] 27 | with_setup(files) do 28 | assert loader::M::X 29 | loader.reload 30 | assert_same mod, loader::M 31 | assert loader::M::X 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /im.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/im/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "im" 7 | spec.summary = "Multiverse autoloader" 8 | spec.description = <<-EOS 9 | Im is a thread-safe code loader for anonymous-rooted namespaces. 10 | EOS 11 | 12 | spec.author = "Chris Salzberg" 13 | spec.email = "chris@dejimata.com" 14 | spec.license = "MIT" 15 | spec.homepage = "https://github.com/shioyama/im" 16 | spec.files = Dir["README.md", "MIT-LICENSE", "lib/**/*.rb"] 17 | spec.version = Im::VERSION 18 | spec.metadata = { 19 | "homepage_uri" => "https://github.com/shioyama/im", 20 | "changelog_uri" => "https://github.com/shioyama/im/blob/master/CHANGELOG.md", 21 | "source_code_uri" => "https://github.com/shioyama/im", 22 | "bug_tracker_uri" => "https://github.com/shioyama/im/issues" 23 | } 24 | 25 | spec.required_ruby_version = ">= 3.2" 26 | end 27 | -------------------------------------------------------------------------------- /test/lib/im/test_shadowed_files.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class TestShadowedFiles < LoaderTest 6 | test "does not autoload from a file shadowed by an existing constant" do 7 | loader::X = 1 8 | 9 | files = [["x.rb", "X = 2"]] 10 | with_setup(files) do 11 | assert loader.__shadowed_file?(File.expand_path("x.rb")) 12 | 13 | assert_equal 1, loader::X 14 | loader.reload 15 | assert_equal 1, loader::X 16 | end 17 | end 18 | 19 | test "does not autoload from a file shadowed by another one managed by the same loader" do 20 | files = [["a/x.rb", "X = 1"], ["b/x.rb", "X = 2"]] 21 | with_files(files) do 22 | loader.push_dir("a") 23 | loader.push_dir("b") 24 | loader.setup 25 | 26 | assert !loader.__shadowed_file?(File.expand_path("a/x.rb")) 27 | assert loader.__shadowed_file?(File.expand_path("b/x.rb")) 28 | 29 | assert_equal 1, loader::X 30 | loader.reload 31 | assert_equal 1, loader::X 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/lib/im/test_import.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class TestImport < LoaderTest 6 | include Im 7 | 8 | def with_setup 9 | files = [ 10 | ["lib/my_gem.rb", <<-EOS], 11 | $import_test_loader = Im::Loader.for_gem 12 | $import_test_loader.setup 13 | 14 | module $import_test_loader::MyGem 15 | end 16 | EOS 17 | ["lib/my_gem/foo.rb", "MyGem::Foo = true"], 18 | ] 19 | with_files(files) do 20 | with_load_path("lib") do 21 | yield 22 | end 23 | end 24 | end 25 | 26 | test "import returns loader for gem" do 27 | on_teardown { $LOADED_FEATURES.pop } 28 | 29 | with_setup do 30 | require "my_gem" 31 | assert_equal import("my_gem"), $import_test_loader 32 | end 33 | end 34 | 35 | test "import is also provided as a class method" do 36 | on_teardown { $LOADED_FEATURES.pop } 37 | 38 | with_setup do 39 | require "my_gem" 40 | assert_equal Im.import("my_gem"), $import_test_loader 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/lib/im/test_multiple_loaders.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class TestMultipleLoaders < LoaderTest 6 | test "multiple dependent loaders" do 7 | files = [ 8 | ["lib0/app0.rb", <<-EOS], 9 | module App0 10 | $test_multiple_loaders_l1::App1 11 | end 12 | EOS 13 | ["lib0/app0/foo.rb", <<-EOS], 14 | class App0::Foo 15 | $test_multiple_loaders_l1::App1::Foo 16 | end 17 | EOS 18 | ["lib1/app1/foo.rb", <<-EOS], 19 | class App1::Foo 20 | $test_multiple_loaders_l0::App0 21 | end 22 | EOS 23 | ["lib1/app1/foo/bar/baz.rb", <<-EOS] 24 | class App1::Foo::Bar::Baz 25 | $test_multiple_loaders_l0::App0::Foo 26 | end 27 | EOS 28 | ] 29 | with_files(files) do 30 | $test_multiple_loaders_l0 = new_loader(dirs: "lib0") 31 | $test_multiple_loaders_l1 = new_loader(dirs: "lib1") 32 | 33 | assert $test_multiple_loaders_l0::App0::Foo 34 | assert $test_multiple_loaders_l1::App1::Foo::Bar::Baz 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 Chris Salzberg 2 | Copyright (c) 2019 Xavier Noria 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining 5 | a copy of this software and associated documentation files (the 6 | "Software"), to deal in the Software without restriction, including 7 | without limitation the rights to use, copy, modify, merge, publish, 8 | distribute, sublicense, and/or sell copies of the Software, and to 9 | permit persons to whom the Software is furnished to do so, subject to 10 | the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 19 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 20 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 21 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/lib/im/test_on_setup.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class TestOnSetup < LoaderTest 6 | test "on_setup callbacks are fired on setup, in order" do 7 | x = [] 8 | loader.on_setup { x << 0 } 9 | loader.on_setup { x << 1 } 10 | loader.setup 11 | 12 | assert_equal [0, 1], x 13 | end 14 | 15 | test "on_setup callbacks are fired if setup was already done" do 16 | loader.setup 17 | 18 | x = [] 19 | loader.on_setup { x << 0 } 20 | loader.on_setup { x << 1 } 21 | 22 | assert_equal [0, 1], x 23 | end 24 | 25 | test "on_setup callbacks are fired again on reload" do 26 | loader.enable_reloading 27 | 28 | x = [] 29 | loader.on_setup { x << 0 } 30 | loader.on_setup { x << 1 } 31 | loader.setup 32 | 33 | assert_equal [0, 1], x 34 | 35 | loader.reload 36 | 37 | assert_equal [0, 1, 0, 1], x 38 | end 39 | 40 | test "on_setup is able to autoload" do 41 | files = [["x.rb", "X = true"]] 42 | with_files(files) do 43 | loader.push_dir(".") 44 | loader.on_setup do 45 | assert loader::X 46 | end 47 | loader.setup 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /test/lib/test_inflector.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class TestInflector < Minitest::Test 6 | def camelize(str) 7 | Im::Inflector.new.camelize(str, nil) 8 | end 9 | 10 | test "capitalizes the first letter" do 11 | assert_equal "User", camelize("user") 12 | end 13 | 14 | test "camelizes snake case basenames" do 15 | assert_equal "UsersController", camelize("users_controller") 16 | end 17 | 18 | test "supports segments that do not capitalize" do 19 | assert_equal "Point3dValue", camelize("point_3d_value") 20 | end 21 | 22 | test "knows nothing about acronyms" do 23 | assert_equal "HtmlParser", camelize("html_parser") 24 | end 25 | 26 | test "returns inflections defined using the inflect method" do 27 | inflections = { 28 | "html_parser" => "HTMLParser", 29 | "csv_controller" => "CSVController", 30 | "mysql_adapter" => "MySQLAdapter" 31 | } 32 | 33 | inflector = Im::Inflector.new 34 | inflector.inflect(inflections) 35 | 36 | inflections.each do |basename, cname| 37 | assert_equal cname, inflector.camelize(basename, nil) 38 | end 39 | 40 | assert_equal "UsersController", inflector.camelize("users_controller", nil) 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/lib/test_gem_inflector.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class TestGemInflector < LoaderTest 6 | def with_setup 7 | files = [ 8 | ["lib/my_gem.rb", <<-EOS], 9 | $gem_inflector_test_loader = Im::Loader.for_gem 10 | $gem_inflector_test_loader.enable_reloading 11 | $gem_inflector_test_loader.setup 12 | 13 | module $gem_inflector_test_loader::MyGem 14 | end 15 | EOS 16 | ["lib/my_gem/foo.rb", "MyGem::Foo = true"], 17 | ["lib/my_gem/version.rb", "MyGem::VERSION = '1.0.0'"], 18 | ["lib/my_gem/ns/version.rb", "MyGem::Ns::Version = true"] 19 | ] 20 | with_files(files) do 21 | with_load_path("lib") do 22 | assert require("my_gem") 23 | yield 24 | end 25 | end 26 | end 27 | 28 | test "the constant for my_gem/version.rb is inflected as VERSION" do 29 | with_setup { assert_equal "1.0.0", $gem_inflector_test_loader::MyGem::VERSION } 30 | end 31 | 32 | test "other possible version.rb are inflected normally" do 33 | with_setup { assert $gem_inflector_test_loader::MyGem::Ns::Version } 34 | end 35 | 36 | test "works as expected for other files" do 37 | with_setup { assert $gem_inflector_test_loader::MyGem::Foo } 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/im/kernel.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Kernel 4 | module_function 5 | 6 | alias_method :im_original_require, :require 7 | class << self 8 | alias_method :im_original_require, :require 9 | end 10 | 11 | # @sig (String) -> true | false 12 | def require(path) 13 | filetype, feature_path = $:.resolve_feature_path(path) 14 | 15 | if (loader = Im::Registry.loader_for(path)) || 16 | ((loader = Im::Registry.loader_for(feature_path)) && (path = feature_path)) 17 | if :rb == filetype 18 | if loaded = !$LOADED_FEATURES.include?(feature_path) 19 | $LOADED_FEATURES << feature_path 20 | begin 21 | load path, loader 22 | rescue => e 23 | $LOADED_FEATURES.delete(feature_path) 24 | raise e 25 | end 26 | loader.on_file_autoloaded(path) 27 | end 28 | loaded 29 | else 30 | loader.on_dir_autoloaded(path) 31 | true 32 | end 33 | else 34 | required = im_original_require(path) 35 | if required 36 | abspath = $LOADED_FEATURES.last 37 | if loader = Im::Registry.loader_for(abspath) 38 | loader.on_file_autoloaded(abspath) 39 | end 40 | end 41 | required 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/im/const_path.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Im 4 | module ConstPath 5 | UNBOUND_METHOD_MODULE_NAME = Module.instance_method(:name) 6 | UNBOUND_METHOD_MODULE_TO_S = Module.instance_method(:to_s) 7 | private_constant :UNBOUND_METHOD_MODULE_NAME, :UNBOUND_METHOD_MODULE_TO_S 8 | 9 | # @sig (Module) -> String 10 | def cpath(mod) 11 | real_mod_name(mod) || real_mod_to_s(mod) 12 | end 13 | 14 | # @sig (Module) -> String? 15 | def permanent_cpath(mod) 16 | name = real_mod_name(mod) 17 | return name unless temporary_name?(name) 18 | end 19 | 20 | # @sig (Module) -> Boolean 21 | def permanent_cpath?(mod) 22 | !temporary_cpath?(mod) 23 | end 24 | 25 | # @sig (Module) -> Boolean 26 | def temporary_cpath?(mod) 27 | temporary_name?(real_mod_name(mod)) 28 | end 29 | 30 | private 31 | 32 | # @sig (Module) -> String 33 | def real_mod_to_s(mod) 34 | UNBOUND_METHOD_MODULE_TO_S.bind_call(mod) 35 | end 36 | 37 | # @sig (Module) -> String? 38 | def real_mod_name(mod) 39 | UNBOUND_METHOD_MODULE_NAME.bind_call(mod) 40 | end 41 | 42 | # @sig (String) -> Boolean 43 | def temporary_name?(name) 44 | # There should be a nicer way to get this in Ruby. 45 | name.nil? || name.start_with?("#") 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/lib/im/test_nested_root_directories.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class TestNestedRootDirectories < LoaderTest 6 | test "nested root directories do not autovivify modules" do 7 | files = [["concerns/pricing.rb", "module Pricing; end"]] 8 | with_setup(files, dirs: %w(. concerns)) do 9 | assert_raises(NameError) { loader::Concerns } 10 | end 11 | end 12 | 13 | test "nested root directories are ignored even if there is a matching file" do 14 | files = [ 15 | ["hotel.rb", "class Hotel; include GeoLoc; end"], 16 | ["concerns/geo_loc.rb", "module GeoLoc; end"], 17 | ["concerns.rb", "module Concerns; end"] 18 | ] 19 | with_setup(files, dirs: %w(. concerns)) do 20 | assert loader::Concerns 21 | assert loader::Hotel 22 | end 23 | end 24 | 25 | test "eager loading handles nested root directories correctly" do 26 | $airplane_eager_loaded = $locatable_eager_loaded = false 27 | files = [ 28 | ["airplane.rb", "class Airplane; $airplane_eager_loaded = true; end"], 29 | ["concerns/locatable.rb", "module Locatable; $locatable_eager_loaded = true; end"] 30 | ] 31 | with_setup(files, dirs: %w(. concerns)) do 32 | loader.eager_load 33 | assert $airplane_eager_loaded 34 | assert $locatable_eager_loaded 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/lib/im/test_ancestors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | # The following properties are not supported by the classic Rails autoloader. 6 | class TestAncestors < LoaderTest 7 | test "autoloads a constant from an ancestor" do 8 | files = [ 9 | ["a.rb", "class A; end"], 10 | ["a/x.rb", "class A::X; end"], 11 | ["b.rb", "class B < A; end"], 12 | ["c.rb", "class C < B; end"] 13 | ] 14 | with_setup(files) do 15 | assert loader::C::X 16 | end 17 | end 18 | 19 | test "autoloads a constant from an ancenstor, even if present above" do 20 | files = [ 21 | ["a.rb", "class A; X = :A; end"], 22 | ["b.rb", "class B < A; end"], 23 | ["b/x.rb", "class B; X = :B; end"], 24 | ["c.rb", "class C < B; end"] 25 | ] 26 | with_setup(files) do 27 | assert_equal :A, loader::A::X 28 | assert_equal :B, loader::C::X 29 | end 30 | end 31 | 32 | # See https://github.com/rails/rails/issues/28997. 33 | test "autoloads a constant from an ancestor that has some nesting going on" do 34 | files = [ 35 | ["test_class.rb", "class TestClass; include IncludeModule; end"], 36 | ["include_module.rb", "module IncludeModule; include ContainerModule; end"], 37 | ["container_module/child_class.rb", "class ContainerModule::ChildClass; end"] 38 | ] 39 | with_setup(files) do 40 | assert loader::TestClass::ChildClass 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/lib/im/test_utf8.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class TestUTF8 < LoaderTest 6 | # In CI, the codepage for the Windows file system for Ruby 2.5, 2.6, and 2.7 7 | # is Windows-1252, and UTF-8 for Ruby >= 3.0. In Ubuntu, the file system is 8 | # encoded in UTF-8 for all supported Ruby versions. 9 | if Encoding::UTF_8 == Encoding.find("filesystem") 10 | test "autoloads in a project whose root directories have accented letters" do 11 | files = [["líb/x.rb", "X = true"]] 12 | with_setup(files, dirs: ["líb"]) do 13 | assert loader::X 14 | end 15 | end 16 | 17 | test "autoloads constants that have accented letters in the middle" do 18 | files = [["màxim.rb", "Màxim = 10_000"]] 19 | with_setup(files) do 20 | assert loader::Màxim 21 | end 22 | end 23 | 24 | test "autoloads constants that start with a Greek letter" do 25 | files = [["ω.rb", "Ω = true"]] 26 | with_setup(files) do 27 | assert loader::Ω 28 | end 29 | end 30 | 31 | test "autoloads implicit namespaces that start with a Greek letter" do 32 | files = [["ω/à.rb", "Ω::À = true"]] 33 | with_setup(files) do 34 | assert loader::Ω::À 35 | end 36 | end 37 | 38 | test "autoloads explicit namespaces that start with a Greek letter" do 39 | files = [ 40 | ["ω.rb", "module Ω; end"], 41 | ["ω/à.rb", "Ω::À = true"] 42 | ] 43 | with_setup(files) do 44 | assert loader::Ω::À 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/im/inflector.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Im 4 | class Inflector 5 | # Very basic snake case -> camel case conversion. 6 | # 7 | # inflector = Im::Inflector.new 8 | # inflector.camelize("post", ...) # => "Post" 9 | # inflector.camelize("users_controller", ...) # => "UsersController" 10 | # inflector.camelize("api", ...) # => "Api" 11 | # 12 | # Takes into account hard-coded mappings configured with `inflect`. 13 | # 14 | # @sig (String, String) -> String 15 | def camelize(basename, _abspath) 16 | overrides[basename] || basename.split('_').each(&:capitalize!).join 17 | end 18 | 19 | # Configures hard-coded inflections: 20 | # 21 | # inflector = Im::Inflector.new 22 | # inflector.inflect( 23 | # "html_parser" => "HTMLParser", 24 | # "mysql_adapter" => "MySQLAdapter" 25 | # ) 26 | # 27 | # inflector.camelize("html_parser", abspath) # => "HTMLParser" 28 | # inflector.camelize("mysql_adapter", abspath) # => "MySQLAdapter" 29 | # inflector.camelize("users_controller", abspath) # => "UsersController" 30 | # 31 | # @sig (Hash[String, String]) -> void 32 | def inflect(inflections) 33 | overrides.merge!(inflections) 34 | end 35 | 36 | private 37 | 38 | # Hard-coded basename to constant name user maps that override the default 39 | # inflection logic. 40 | # 41 | # @sig () -> Hash[String, String] 42 | def overrides 43 | @overrides ||= {} 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /test/lib/im/test_private_constants.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class TestPrivateConstants < LoaderTest 6 | test "lookup rules for relative private constants work as expected" do 7 | files = [["m/x.rb", <<~EOS1], ["c.rb", <<~EOS2]] 8 | module M 9 | X = :X 10 | private_constant :X 11 | end 12 | EOS1 13 | class C 14 | include M 15 | 16 | def self.x 17 | X 18 | end 19 | end 20 | EOS2 21 | with_setup(files) do 22 | assert_equal :X, loader::C.x 23 | end 24 | end 25 | 26 | test "lookup rules for qualified private constants work as expected" do 27 | files = [["m/x.rb", <<~RUBY]] 28 | module M 29 | X = :X 30 | private_constant :X 31 | end 32 | RUBY 33 | with_setup(files) do 34 | assert_raises(NameError) { loader::M::X } 35 | assert_equal :X, loader::M.module_eval("X") 36 | end 37 | end 38 | 39 | test "reloading works for private constants" do 40 | $test_reload_private_constants = 0 41 | files = [["m/x.rb", <<~EOS1], ["c.rb", <<~EOS2]] 42 | module M 43 | X = $test_reload_private_constants 44 | private_constant :X 45 | end 46 | EOS1 47 | class C 48 | include M 49 | 50 | def self.x 51 | X 52 | end 53 | end 54 | EOS2 55 | with_setup(files) do 56 | assert_equal 0, loader::C.x 57 | 58 | loader.reload 59 | $test_reload_private_constants = 1 60 | 61 | assert_equal 1, loader::C.x 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /test/lib/im/test_callbacks.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class TestCallbacks < LoaderTest 6 | test "autoloading a file triggers on_file_autoloaded" do 7 | def loader.on_file_autoloaded(file) 8 | if file == File.expand_path("x.rb") 9 | $on_file_autoloaded_called = true 10 | end 11 | super 12 | end 13 | 14 | files = [["x.rb", "X = true"]] 15 | with_setup(files) do 16 | $on_file_autoloaded_called = false 17 | assert loader::X 18 | assert $on_file_autoloaded_called 19 | end 20 | end 21 | 22 | test "requiring an autoloadable file triggers on_file_autoloaded" do 23 | def loader.on_file_autoloaded(file) 24 | if file == File.expand_path("y.rb") 25 | $on_file_autoloaded_called = true 26 | end 27 | super 28 | end 29 | 30 | files = [ 31 | ["x.rb", "X = true"], 32 | ["y.rb", "Y = X"] 33 | ] 34 | with_setup(files, load_path: ".") do 35 | $on_file_autoloaded_called = false 36 | require "y" 37 | assert loader::Y 38 | assert $on_file_autoloaded_called 39 | end 40 | end 41 | 42 | test "autoloading a directory triggers on_dir_autoloaded" do 43 | def loader.on_dir_autoloaded(dir) 44 | if dir == File.expand_path("m") 45 | $on_dir_autoloaded_called = true 46 | end 47 | super 48 | end 49 | 50 | files = [["m/x.rb", "M::X = true"]] 51 | with_setup(files) do 52 | $on_dir_autoloaded_called = false 53 | assert loader::M::X 54 | assert $on_dir_autoloaded_called 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /test/lib/im/test_unregister.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class TestUnregister < LoaderTest 6 | test "unregister removes the loader from internal state" do 7 | loader1 = Im::Loader.new 8 | registry = Im::Registry 9 | registry.register_loader(loader1) 10 | registry.gem_loaders_by_root_file["dummy1"] = loader1 11 | registry.register_autoload(loader1, "dummy1") 12 | registry.register_inception("dummy1", "dummy1", loader1) 13 | Im::ExplicitNamespace.__register("dummy1", "dummyname1", loader1) 14 | 15 | loader2 = Im::Loader.new 16 | registry = Im::Registry 17 | registry.register_loader(loader2) 18 | registry.gem_loaders_by_root_file["dummy2"] = loader2 19 | registry.register_autoload(loader2, "dummy2") 20 | registry.register_inception("dummy2", "dummy2", loader2) 21 | Im::ExplicitNamespace.__register("dummy2", "dummyname2", loader2) 22 | 23 | loader1.unregister 24 | 25 | assert !registry.loaders.include?(loader1) 26 | assert !registry.gem_loaders_by_root_file.values.include?(loader1) 27 | assert !registry.autoloads.values.include?(loader1) 28 | assert !registry.inceptions.values.any? {|_, l| l == loader1} 29 | assert Im::ExplicitNamespace.send(:cpaths).values.none? { |_, l| loader1 == l } 30 | 31 | assert registry.loaders.include?(loader2) 32 | assert registry.gem_loaders_by_root_file.values.include?(loader2) 33 | assert registry.autoloads.values.include?(loader2) 34 | assert registry.inceptions.values.any? {|_, l| l == loader2} 35 | assert Im::ExplicitNamespace.send(:cpaths).values.any? { |_, l| loader2 == l } 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/lib/im/test_push_dir.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "pathname" 5 | 6 | class TesPushDir < LoaderTest 7 | def check_dirs 8 | root_dirs = loader.__root_dirs 9 | 10 | non_ignored_root_dirs = root_dirs.reject { |dir| loader.send(:ignored_path?, dir) }.to_set 11 | 12 | dirs = loader.dirs 13 | assert_equal non_ignored_root_dirs, dirs.to_set 14 | assert dirs.frozen? 15 | 16 | dirs = loader.dirs(ignored: true) 17 | assert_equal root_dirs, dirs.to_set 18 | assert dirs.frozen? 19 | end 20 | 21 | test "accepts dirs as strings and associates them to the Object namespace" do 22 | loader.push_dir(".") 23 | check_dirs 24 | end 25 | 26 | test "accepts dirs as pathnames and associates them to the Object namespace" do 27 | loader.push_dir(Pathname.new(".")) 28 | check_dirs 29 | end 30 | 31 | test "there can be several root directories" do 32 | files = [["rd1/x.rb", "X = 1"], ["rd2/y.rb", "Y = 1"], ["rd3/z.rb", "Z = 1"]] 33 | with_setup(files) do 34 | check_dirs 35 | end 36 | end 37 | 38 | test "there can be several root directories, some of them may be ignored" do 39 | files = [["rd1/x.rb", "X = 1"], ["rd2/y.rb", "Y = 1"], ["rd3/z.rb", "Z = 1"]] 40 | with_files(files) do 41 | loader.push_dir("rd1") 42 | loader.push_dir("rd2") 43 | loader.push_dir("rd3") 44 | loader.ignore("rd2") 45 | check_dirs 46 | end 47 | end 48 | 49 | test "raises on non-existing directories" do 50 | dir = File.expand_path("non-existing") 51 | e = assert_raises(Im::Error) { loader.push_dir(dir) } 52 | assert_equal "the root directory #{dir} does not exist", e.message 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/lib/im/test_marshal.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class TestMarshal < LoaderTest 6 | test "Marshal.load autoloads a top-level class" do 7 | on_teardown { remove_const :C, from: self.class } 8 | 9 | files = [["c.rb", "class C; end"]] 10 | with_setup(files) do 11 | C = loader::C 12 | str = Marshal.dump(C.new) 13 | loader.reload 14 | assert_instance_of C, Marshal.load(str) 15 | end 16 | end 17 | 18 | test "Marshal.load autoloads a namespaced class (implicit)" do 19 | on_teardown { remove_const :M, from: self.class } 20 | 21 | files = [["m/n/c.rb", "class M::N::C; end"]] 22 | with_setup(files) do 23 | M = loader::M 24 | str = Marshal.dump(M::N::C.new) 25 | loader.reload 26 | assert_instance_of M::N::C, Marshal.load(str) 27 | end 28 | end 29 | 30 | test "Marshal.load autoloads a namespaced class (explicit)" do 31 | on_teardown { remove_const :M, from: self.class } 32 | 33 | files = [ 34 | ["m.rb", "module M; end"], 35 | ["m/n/c.rb", "class M::N::C; end"] 36 | ] 37 | with_setup(files) do 38 | M = loader::M 39 | str = Marshal.dump(M::N::C.new) 40 | loader.reload 41 | assert_instance_of M::N::C, Marshal.load(str) 42 | end 43 | end 44 | 45 | test "Marshal.load autoloads several classes" do 46 | on_teardown do 47 | remove_const :C, from: self.class 48 | remove_const :D, from: self.class 49 | end 50 | 51 | files = [ 52 | ["c.rb", "class C; end"], 53 | ["d.rb", "class D; end"] 54 | ] 55 | with_setup(files) do 56 | C = loader::C 57 | D = loader::D 58 | str = Marshal.dump([C.new, D.new]) 59 | loader.reload 60 | loaded = Marshal.load(str) 61 | assert_instance_of C, loaded[0] 62 | assert_instance_of D, loaded[1] 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /test/lib/im/test_top_level.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class TestTopLevel < LoaderTest 6 | test "autoloads a simple constant in a top-level file (Object)" do 7 | files = [["x.rb", "X = true"]] 8 | with_setup(files) do 9 | assert loader::X 10 | end 11 | end 12 | 13 | test "autoloads a simple class in a top-level file (Object)" do 14 | files = [["user.rb", "class User; end"]] 15 | with_setup(files) do 16 | assert loader::User 17 | end 18 | end 19 | 20 | test "autoloads several top-level classes" do 21 | files = [ 22 | ["rd1/user.rb", "class User; end"], 23 | ["rd2/users_controller.rb", "class UsersController; User; end"] 24 | ] 25 | with_setup(files) do 26 | assert loader::UsersController 27 | end 28 | end 29 | 30 | test "autoloads only the first of multiple occurrences" do 31 | files = [ 32 | ["rd1/user.rb", "User = :model"], 33 | ["rd2/user.rb", "User = :decorator"], 34 | ] 35 | with_setup(files) do 36 | assert_equal :model, loader::User 37 | end 38 | end 39 | 40 | test "anything other than Ruby and visible directories is ignored" do 41 | files = [ 42 | ["x.txt", ""], # Programmer notes 43 | ["x.lua", ""], # Lua files for Redis 44 | ["x.yaml", ""], # Included configuration 45 | ["x.json", ""], # Included configuration 46 | ["x.erb", ""], # Included template 47 | ["x.jpg", ""], # Included image 48 | ["x.rb~", ""], # Emacs auto backup 49 | ["#x.rb#", ""], # Emacs auto save 50 | [".filename.swp", ""], # Vim swap file 51 | ["4913", ""], # May be created by Vim 52 | [".idea/workspace.xml", ""] # RubyMine 53 | ] 54 | with_setup(files) do 55 | assert_empty loader.__autoloads 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/im/gem_loader.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Im 4 | # @private 5 | class GemLoader < Loader 6 | # Users should not create instances directly, the public interface is 7 | # `Im::Loader.for_gem`. 8 | private_class_method :new 9 | 10 | # @private 11 | # @sig (String, bool) -> Im::GemLoader 12 | def self._new(root_file, warn_on_extra_files:) 13 | new(root_file, warn_on_extra_files: warn_on_extra_files) 14 | end 15 | 16 | # @sig (String, bool) -> void 17 | def initialize(root_file, warn_on_extra_files:) 18 | super() 19 | 20 | @tag = File.basename(root_file, ".rb") 21 | @inflector = GemInflector.new(root_file) 22 | @root_file = File.expand_path(root_file) 23 | @lib = File.dirname(root_file) 24 | @warn_on_extra_files = warn_on_extra_files 25 | 26 | push_dir(@lib) 27 | end 28 | 29 | # @sig () -> void 30 | def setup 31 | warn_on_extra_files if @warn_on_extra_files 32 | super 33 | end 34 | 35 | private 36 | 37 | # @sig () -> void 38 | def warn_on_extra_files 39 | expected_namespace_dir = @root_file.delete_suffix(".rb") 40 | 41 | ls(@lib) do |basename, abspath| 42 | next if abspath == @root_file 43 | next if abspath == expected_namespace_dir 44 | 45 | basename_without_ext = basename.delete_suffix(".rb") 46 | cname = inflector.camelize(basename_without_ext, abspath).to_sym 47 | ftype = dir?(abspath) ? "directory" : "file" 48 | 49 | warn(<<~EOS) 50 | WARNING: Im defines the constant #{cname} after the #{ftype} 51 | 52 | #{abspath} 53 | 54 | To prevent that, please configure the loader to ignore it: 55 | 56 | loader.ignore("\#{__dir__}/#{basename}") 57 | 58 | Otherwise, there is a flag to silence this warning: 59 | 60 | Im::Loader.for_gem(warn_on_extra_files: false) 61 | EOS 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /test/lib/im/test_sti_old_school_workaround.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | # Rails applications are expected to preload STIs. Using requires is the old 6 | # school way to address this and it is somewhat tricky. Let's have a test to 7 | # make sure the circularity works. 8 | class TestOldSchoolWorkaroundSTI < LoaderTest 9 | def files 10 | [ 11 | ["a.rb", <<-EOS], 12 | class A 13 | require 'b' 14 | end 15 | $test_sti_loaded << 'A' 16 | EOS 17 | ["b.rb", <<-EOS], 18 | class B < A 19 | require 'c' 20 | end 21 | $test_sti_loaded << 'B' 22 | EOS 23 | ["c.rb", <<-EOS], 24 | class C < B 25 | require 'd1' 26 | require 'd2' 27 | end 28 | $test_sti_loaded << 'C' 29 | EOS 30 | ["d1.rb", "class D1 < C; end; $test_sti_loaded << 'D1'"], 31 | ["d2.rb", "class D2 < C; end; $test_sti_loaded << 'D2'"] 32 | ] 33 | end 34 | 35 | def with_setup 36 | original_verbose = $VERBOSE 37 | $VERBOSE = nil # To avoid circular require warnings. 38 | 39 | $test_sti_loaded = [] 40 | 41 | super(files, load_path: ".") do 42 | yield 43 | end 44 | ensure 45 | $VERBOSE = original_verbose 46 | end 47 | 48 | def assert_all_loaded 49 | assert_equal %w(A B C D1 D2), $test_sti_loaded.sort 50 | end 51 | 52 | test "loading the root loads everything" do 53 | with_setup do 54 | assert loader::A 55 | assert_all_loaded 56 | end 57 | end 58 | 59 | test "loading a root child loads everything" do 60 | with_setup do 61 | assert loader::B 62 | assert_all_loaded 63 | end 64 | end 65 | 66 | test "loading an intermediate descendant loads everything" do 67 | with_setup do 68 | assert loader::C 69 | assert_all_loaded 70 | end 71 | end 72 | 73 | test "loading a leaf loads everything" do 74 | with_setup do 75 | assert loader::D1 76 | assert_all_loaded 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /test/lib/im/test_unloadable_cpath.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "set" 5 | 6 | class TestUnloadableCpath < LoaderTest 7 | test "a loader that has loading nothing, has nothing to unload" do 8 | files = [["x.rb", "X = true"]] 9 | with_setup(files) do 10 | assert_empty loader.unloadable_cpaths 11 | assert !loader.unloadable_cpath?("X") 12 | end 13 | end 14 | 15 | test "a loader that loaded some stuff has that stuff to be unloaded if reloading is enabled" do 16 | files = [ 17 | ["m/x.rb", "M::X = true"], 18 | ["m/y.rb", "M::Y = true"], 19 | ["z.rb", "Z = true"] 20 | ] 21 | with_setup(files) do 22 | assert loader::M::X 23 | 24 | assert_equal ["M", "M::X"], loader.unloadable_cpaths 25 | 26 | assert loader.unloadable_cpath?("M") 27 | assert loader.unloadable_cpath?("M::X") 28 | 29 | assert !loader.unloadable_cpath?("M::Y") 30 | assert !loader.unloadable_cpath?("Z") 31 | end 32 | end 33 | 34 | test "unloadable_cpaths returns actual constant paths even if #name is overridden" do 35 | files = [["m.rb", <<~RUBY], ["m/c.rb", "M::C = true"]] 36 | module M 37 | def self.name 38 | "X" 39 | end 40 | end 41 | RUBY 42 | with_setup(files) do 43 | assert loader::M::C 44 | assert loader.unloadable_cpath?("M::C") 45 | end 46 | end 47 | 48 | test "a loader that loaded some stuff has nothing to unload if reloading is disabled" do 49 | on_teardown do 50 | delete_loaded_feature "m/x.rb" 51 | delete_loaded_feature "m/y.rb" 52 | delete_loaded_feature "z.rb" 53 | end 54 | 55 | files = [ 56 | ["m/x.rb", "M::X = true"], 57 | ["m/y.rb", "M::Y = true"], 58 | ["z.rb", "Z = true"] 59 | ] 60 | with_files(files) do 61 | loader = new_loader(dirs: ".", enable_reloading: false) 62 | 63 | assert loader::M::X 64 | assert loader::M::Y 65 | assert loader::Z 66 | 67 | assert_empty loader.unloadable_cpaths 68 | assert loader.__to_unload.empty? 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /test/lib/im/test_const_added.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class TestConstAdded < LoaderTest 6 | def files 7 | [ 8 | ["a.rb", <<-EOS1], ["a/b.rb", <<~EOS2], ["a/b/c.rb", <<~EOS3] 9 | module A; end 10 | EOS1 11 | module A 12 | module B 13 | end 14 | end 15 | EOS2 16 | module A 17 | module B 18 | module C 19 | end 20 | end 21 | end 22 | EOS3 23 | ] 24 | end 25 | 26 | test "loads nested constants correctly after root has been named" do 27 | on_teardown { remove_const :X, from: self.class } 28 | 29 | with_setup(files) do 30 | assert loader::A 31 | X = loader::A 32 | assert loader::A 33 | assert loader::A::B 34 | assert loader::A::B::C 35 | assert_equal(X::B::C, loader::A::B::C) 36 | end 37 | end 38 | 39 | test "multiple constant aliases" do 40 | on_teardown do 41 | remove_const :X, from: self.class 42 | remove_const :Y, from: self.class 43 | end 44 | 45 | with_setup(files) do 46 | assert loader::A 47 | X = loader::A 48 | assert loader::A 49 | Y = X 50 | assert loader::A::B 51 | assert loader::A::B::C 52 | end 53 | end 54 | 55 | test "compatible with reload" do 56 | on_teardown { remove_const :X, from: self.class } 57 | 58 | with_setup(files) do 59 | loader.enable_reloading 60 | X = loader::A::B 61 | assert(X::C) 62 | loader.reload 63 | assert(X::C) 64 | end 65 | end 66 | 67 | test "compatible with reloading of constants that have been aliased" do 68 | on_teardown do 69 | remove_const :X, from: self.class 70 | remove_const :Y, from: self.class 71 | remove_const :Z, from: self.class 72 | end 73 | 74 | with_setup(files) do 75 | loader.enable_reloading 76 | X = loader::A 77 | Y = loader::A 78 | Z = loader::A::B 79 | assert(X::B) 80 | assert(Y::B) 81 | assert(Z::C) 82 | loader.reload 83 | assert(X::B) 84 | assert(Y::B) 85 | assert(Z::C) 86 | end 87 | end 88 | 89 | test "named root" do 90 | on_teardown { remove_const :X, from: self.class } 91 | 92 | with_setup(files) do 93 | loader.enable_reloading 94 | X = loader 95 | assert(X::A) 96 | assert(X::A::B) 97 | loader.reload 98 | assert(X::A) 99 | assert(X::A::B) 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/im/module_const_added.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Im::ModuleConstAdded 4 | # We patch Module#const_added to track every time a constant is added to a 5 | # permanently-named module pointing to an Im-autoloaded constant. This is 6 | # important because the moment that an Im-autoloaded constant is attached to 7 | # a permanently named module, its name changes permanently. Although Im 8 | # internally avoids the use of absolute cpaths, ExplicitNamespace must use 9 | # them and thus we need to update its internal registry accordingly. 10 | # 11 | # @sig (Symbol) -> void 12 | def const_added(const_name) 13 | # If we are called from an autoload, no need to track. 14 | return super if autoload?(const_name) 15 | 16 | # Get the name of this module and only continue if it is a permanent name. 17 | return unless cpath = Im.permanent_cpath(self) 18 | 19 | # We know this is not an autoloaded constant, so it is safe to fetch the 20 | # value. We fetch the value, get it's hash, and check the registry to see 21 | # if it is an Im-autoloaded module. 22 | relative_cpath, loader, references = Im::Registry.autoloaded_modules[const_get(const_name).hash] 23 | return super unless loader 24 | 25 | # Update the context for this const add. This is important for reloading so 26 | # we can reset inbound references when the autoloaded module is unloaded. 27 | references << [self, const_name] 28 | 29 | # Update all absolute cpath references to this module by replacing all 30 | # references to the original cpath with the new, permanently-named cpath. 31 | # 32 | # For example, if we had a module loader::Foo::Bar, and loader::Foo was 33 | # assigned to Baz like this: 34 | # 35 | # Baz = loader::Foo 36 | # 37 | # then we must update cpaths from a string like 38 | # 39 | # "#::Foo::Bar" 40 | # 41 | # to 42 | # 43 | # "Baz::Bar" 44 | # 45 | # To do this, we take the loader's module_prefix ("#::"), 46 | # append to it the relative cpath of the constant ("Foo") and replace that by the new 47 | # name ("Baz"), roughly like this: 48 | # 49 | # "#::Foo::Bar".gsub(/^#{"#::Foo"}/, "Baz") 50 | # 51 | prefix = relative_cpath ? "#{loader.module_prefix}#{relative_cpath}" : loader.module_prefix.delete_suffix("::") 52 | ::Im::ExplicitNamespace.__update_cpaths(prefix, "#{cpath}::#{const_name}") 53 | 54 | super 55 | rescue NameError 56 | super 57 | end 58 | end 59 | 60 | ::Module.prepend(Im::ModuleConstAdded) 61 | -------------------------------------------------------------------------------- /test/lib/im/test_exceptions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class TestExceptions < LoaderTest 6 | # We cannot test error.message only because after 7 | # 8 | # https://github.com/ruby/ruby/commit/e94604966572bb43fc887856d54aa54b8e9f7719 9 | # 10 | # error.message includes the line of code that raised. 11 | def assert_error_message(message, error) 12 | assert_equal message, error.message.lines.first.chomp 13 | end 14 | 15 | test "raises NameError if the expected constant is not defined" do 16 | files = [["typo.rb", "TyPo = 1"]] 17 | with_setup(files) do 18 | typo_rb = File.expand_path("typo.rb") 19 | error = assert_raises(Im::NameError) { loader::Typo } 20 | assert_error_message "expected file #{typo_rb} to define constant #{loader}::Typo, but didn't", error 21 | assert_equal :Typo, error.name 22 | end 23 | end 24 | 25 | test "eager loading raises NameError if files do not define the expected constants" do 26 | files = [["x.rb", ""]] 27 | with_setup(files) do 28 | x_rb = File.expand_path("x.rb") 29 | error = assert_raises(Im::NameError) { loader.eager_load } 30 | assert_error_message "expected file #{x_rb} to define constant #{loader}::X, but didn't", error 31 | assert_equal :X, error.name 32 | end 33 | end 34 | 35 | test "eager loading raises NameError if a namespace has not been loaded yet" do 36 | on_teardown do 37 | delete_loaded_feature 'cli/x.rb' 38 | end 39 | 40 | files = [["cli/x.rb", "module CLI; X = 1; end"]] 41 | with_setup(files) do 42 | cli_x_rb = File.expand_path("cli/x.rb") 43 | error = assert_raises(Im::NameError) { loader.eager_load } 44 | assert_error_message "expected file #{cli_x_rb} to define constant #{loader}::Cli::X, but didn't", error 45 | assert_equal :X, error.name 46 | end 47 | end 48 | 49 | test "raises if the file does" do 50 | files = [["raises.rb", "Raises = 1; raise 'foo'"]] 51 | with_setup(files, rm: false) do 52 | assert_raises(RuntimeError, "foo") { loader::Raises } 53 | end 54 | end 55 | 56 | test "raises Im::NameError if the inflector returns an invalid constant name for a file" do 57 | files = [["foo-bar.rb", "FooBar = 1"]] 58 | error = assert_raises Im::NameError do 59 | with_setup(files) {} 60 | end 61 | assert_equal :"Foo-bar", error.name 62 | assert_includes error.message, "wrong constant name Foo-bar" 63 | assert_includes error.message, "Tell Im to ignore this particular file." 64 | end 65 | 66 | test "raises Im::NameError if the inflector returns an invalid constant name for a directory" do 67 | files = [["foo-bar/baz.rb", "FooBar::Baz = 1"]] 68 | error = assert_raises Im::NameError do 69 | with_setup(files) {} 70 | end 71 | assert_equal :"Foo-bar", error.name 72 | assert_includes error.message, "wrong constant name Foo-bar" 73 | assert_includes error.message, "Tell Im to ignore this particular directory." 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /test/support/loader_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class LoaderTest < Minitest::Test 4 | TMP_DIR = File.expand_path("../tmp", __dir__) 5 | 6 | attr_reader :loader 7 | 8 | def setup 9 | @loader = new_loader(setup: false) 10 | end 11 | 12 | def new_loader(dirs: [], enable_reloading: true, setup: true) 13 | Im::Loader.new.tap do |loader| 14 | Array(dirs).each { |dir| loader.push_dir(dir) } 15 | loader.enable_reloading if enable_reloading 16 | loader.setup if setup 17 | end 18 | end 19 | 20 | def reset_constants 21 | Im::Registry.loaders.each do |loader| 22 | begin 23 | loader.unload 24 | rescue Im::SetupRequired 25 | end 26 | end 27 | end 28 | 29 | def reset_registry 30 | Im::Registry.loaders.clear 31 | Im::Registry.gem_loaders_by_root_file.clear 32 | Im::Registry.autoloads.clear 33 | Im::Registry.paths.clear 34 | Im::Registry.inceptions.clear 35 | Im::Registry.autoloaded_modules.clear 36 | end 37 | 38 | def reset_explicit_namespace 39 | Im::ExplicitNamespace.send(:cpaths).clear 40 | Im::ExplicitNamespace.send(:tracer).disable 41 | end 42 | 43 | def teardown 44 | reset_constants 45 | reset_registry 46 | reset_explicit_namespace 47 | end 48 | 49 | def mkdir_test 50 | FileUtils.rm_rf(TMP_DIR) 51 | FileUtils.mkdir_p(TMP_DIR) 52 | end 53 | 54 | def with_files(files, rm: true) 55 | mkdir_test 56 | 57 | Dir.chdir(TMP_DIR) do 58 | files.each do |fname, contents| 59 | FileUtils.mkdir_p(File.dirname(fname)) 60 | File.write(fname, contents) 61 | end 62 | yield 63 | end 64 | ensure 65 | mkdir_test if rm 66 | end 67 | 68 | def with_load_path(dirs = loader.dirs) 69 | dirs = Array(dirs).map { |dir| File.expand_path(dir) } 70 | dirs.each { |dir| $LOAD_PATH.push(dir) } 71 | yield 72 | ensure 73 | dirs.each { |dir| $LOAD_PATH.delete(dir) } 74 | end 75 | 76 | def with_setup(files = [], dirs: nil, load_path: nil, rm: true) 77 | with_files(files, rm: rm) do 78 | dirs ||= files.map do |file| 79 | file[0] =~ %r{\A(rd\d+)/} ? $1 : "." 80 | end.uniq 81 | dirs.each { |dir| loader.push_dir(dir) } 82 | 83 | files.each do |file| 84 | if File.basename(file[0]) == "ignored.rb" 85 | loader.ignore(file[0]) 86 | elsif file[0] =~ %r{\A(ignored|.+/ignored)/} 87 | loader.ignore($1) 88 | end 89 | 90 | if file[0] =~ %r{\A(collapsed|.+/collapsed)/} 91 | loader.collapse($1) 92 | end 93 | end 94 | 95 | loader.setup 96 | if load_path 97 | with_load_path(load_path) { yield } 98 | else 99 | yield 100 | end 101 | end 102 | end 103 | 104 | def required?(file_or_files) 105 | if file_or_files.is_a?(String) 106 | $LOADED_FEATURES.include?(File.expand_path(file_or_files, TMP_DIR)) 107 | elsif file_or_files[0].is_a?(String) 108 | required?(file_or_files[0]) 109 | else 110 | file_or_files.all? { |f| required?(f) } 111 | end 112 | end 113 | 114 | def assert_abspath(expected, actual) 115 | assert_equal(File.expand_path(expected, TMP_DIR), actual) 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /test/lib/im/test_load_file.rb: -------------------------------------------------------------------------------- 1 | require "pathname" 2 | require "test_helper" 3 | 4 | class TestLoadFile < LoaderTest 5 | test "loads a top-level file" do 6 | files = [["x.rb", "X = 1"]] 7 | with_setup(files) do 8 | loader.load_file("x.rb") 9 | assert required?(files[0]) 10 | end 11 | end 12 | 13 | test "loads a top-level file (Pathname)" do 14 | files = [["x.rb", "X = 1"]] 15 | with_setup(files) do 16 | loader.load_file(Pathname.new("x.rb")) 17 | assert required?(files[0]) 18 | end 19 | end 20 | 21 | test "loads a namespaced file" do 22 | files = [["m/x.rb", "M::X = 1"]] 23 | with_setup(files) do 24 | loader.load_file("m/x.rb") 25 | assert required?(files[0]) 26 | end 27 | end 28 | 29 | test "supports collapsed directories" do 30 | files = [["m/collapsed/x.rb", "M::X = 1"]] 31 | with_setup(files) do 32 | loader.load_file("m/collapsed/x.rb") 33 | assert required?(files[0]) 34 | end 35 | end 36 | end 37 | 38 | class TestLoadFileErrors < LoaderTest 39 | test "raises if the argument does not exist" do 40 | with_setup do 41 | e = assert_raises { loader.load_file("foo.rb") } 42 | assert_equal "#{File.expand_path('foo.rb')} does not exist", e.message 43 | end 44 | end 45 | 46 | test "raises if the argument is a directory" do 47 | with_setup([["m/x.rb", "M::X = 1"]]) do 48 | e = assert_raises { loader.load_file("m") } 49 | assert_equal "#{File.expand_path('m')} is not a Ruby file", e.message 50 | end 51 | end 52 | 53 | test "raises if the argument is a file, but does not have .rb extension" do 54 | with_setup([["README.md", ""]]) do 55 | e = assert_raises { loader.load_file("README.md") } 56 | assert_equal "#{File.expand_path('README.md')} is not a Ruby file", e.message 57 | end 58 | end 59 | 60 | test "raises if the argument is ignored" do 61 | with_setup([["ignored.rb", "IGNORED"]]) do 62 | e = assert_raises { loader.load_file("ignored.rb") } 63 | assert_equal "#{File.expand_path('ignored.rb')} is ignored", e.message 64 | end 65 | end 66 | 67 | test "raises if the argument is a descendant of an ignored directory" do 68 | with_setup([["ignored/n/x.rb", "IGNORED"]]) do 69 | e = assert_raises { loader.load_file("ignored/n/x.rb") } 70 | assert_equal "#{File.expand_path('ignored/n/x.rb')} is ignored", e.message 71 | end 72 | end 73 | 74 | test "raises if the argument lives in an ignored root directory" do 75 | files = [["ignored/n/x.rb", "IGNORED"]] 76 | with_setup(files, dirs: %w(ignored)) do 77 | e = assert_raises { loader.load_file("ignored/n/x.rb") } 78 | assert_equal "#{File.expand_path('ignored/n/x.rb')} is ignored", e.message 79 | end 80 | end 81 | 82 | test "raises if the file exists, but it is not managed by this loader" do 83 | files = [["rd1/x.rb", "X = 1"], ["external/x.rb", ""]] 84 | with_setup(files, dirs: %w(rd1)) do 85 | e = assert_raises { loader.load_file("external/x.rb") } 86 | assert_equal "I do not manage #{File.expand_path('external/x.rb')}", e.message 87 | end 88 | end 89 | 90 | test "raises if the file is shadowed" do 91 | files = [["rd1/x.rb", "X = 1"], ["rd2/x.rb", "SHADOWED"]] 92 | with_setup(files) do 93 | e = assert_raises { loader.load_file("rd2/x.rb") } 94 | assert_equal "#{File.expand_path('rd2/x.rb')} is shadowed", e.message 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /test/lib/im/test_conflicting_directory.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class TestConflictingDirectory < LoaderTest 6 | def dir 7 | __dir__ 8 | end 9 | 10 | def parent 11 | File.expand_path("..", dir) 12 | end 13 | 14 | def existing_loader 15 | @existing_loader ||= new_loader(setup: false) 16 | end 17 | 18 | def loader 19 | @loader ||= new_loader(setup: false) 20 | end 21 | 22 | def conflicting_directory_message(dir) 23 | require "pp" 24 | "loader\n\n#{loader.pretty_inspect}\n\nwants to manage directory #{dir}," \ 25 | " which is already managed by\n\n#{existing_loader.pretty_inspect}\n" 26 | end 27 | 28 | test "raises if an existing loader manages the same root dir" do 29 | existing_loader.push_dir(dir) 30 | 31 | e = assert_raises(Im::Error) { loader.push_dir(dir) } 32 | assert_equal conflicting_directory_message(dir), e.message 33 | end 34 | 35 | test "raises if an existing loader manages a parent directory" do 36 | existing_loader.push_dir(parent) 37 | 38 | e = assert_raises(Im::Error) { loader.push_dir(dir) } 39 | assert_equal conflicting_directory_message(dir), e.message 40 | end 41 | 42 | test "raises if an existing loader manages a subdirectory" do 43 | existing_loader.push_dir(dir) 44 | 45 | e = assert_raises(Im::Error) { loader.push_dir(parent) } 46 | assert_equal conflicting_directory_message(parent), e.message 47 | end 48 | 49 | test "does not raise if an existing loader manages a directory with a matching prefix" do 50 | files = [["foo/x.rb", "X = 1"], ["foobar/y.rb", "Y = 1"]] 51 | with_files(files) do 52 | existing_loader.push_dir("foo") 53 | assert loader.push_dir("foobar") 54 | end 55 | end 56 | 57 | test "does not raise if an existing loader ignores the directory (dir)" do 58 | existing_loader.push_dir(parent) 59 | existing_loader.ignore(dir) 60 | assert loader.push_dir(dir) 61 | end 62 | 63 | test "does not raise if a second existing loader ignores the directory (dir)" do 64 | # Ensure this loader is loaded 65 | existing_loader 66 | second_existing_loader = new_loader(setup: false) 67 | second_existing_loader.push_dir(parent) 68 | second_existing_loader.ignore(dir) 69 | 70 | assert loader.push_dir(dir) 71 | end 72 | 73 | test "does not raise if an existing loader ignores the directory (glob pattern)" do 74 | existing_loader.push_dir(parent) 75 | existing_loader.ignore("#{parent}/*") 76 | assert loader.push_dir(dir) 77 | end 78 | 79 | test "does not raise if the loader ignores a directory managed by an existing loader (dir)" do 80 | existing_loader.push_dir(dir) 81 | loader.ignore(dir) 82 | assert loader.push_dir(parent) 83 | end 84 | 85 | test "does not raise if the loader ignores a directory managed by an existing loader (glob pattern)" do 86 | existing_loader.push_dir(dir) 87 | loader.ignore("#{parent}/*") 88 | assert loader.push_dir(parent) 89 | end 90 | 91 | test "raises if an existing loader ignores a directory with a matching prefix" do 92 | files = [["foo/x.rb", "X = 1"], ["foobar/y.rb", "Y = 1"]] 93 | with_files(files) do 94 | ignored = File.expand_path("foo") 95 | conflicting_dir = File.expand_path("foobar") 96 | 97 | existing_loader.push_dir(".") 98 | existing_loader.ignore(ignored) 99 | 100 | e = assert_raises(Im::Error) { loader.push_dir(conflicting_dir) } 101 | assert_equal conflicting_directory_message(conflicting_dir), e.message 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /lib/im/loader/helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Im::Loader::Helpers 4 | # --- Logging ----------------------------------------------------------------------------------- 5 | 6 | # @sig (String) -> void 7 | private def log(message) 8 | method_name = logger.respond_to?(:debug) ? :debug : :call 9 | logger.send(method_name, "Im@#{tag}: #{message}") 10 | end 11 | 12 | # --- Files and directories --------------------------------------------------------------------- 13 | 14 | # @sig (String) { (String, String) -> void } -> void 15 | private def ls(dir) 16 | children = Dir.children(dir) 17 | 18 | # The order in which a directory is listed depends on the file system. 19 | # 20 | # Since client code may run in different platforms, it seems convenient to 21 | # order directory entries. This provides consistent eager loading across 22 | # platforms, for example. 23 | children.sort! 24 | 25 | children.each do |basename| 26 | next if hidden?(basename) 27 | 28 | abspath = File.join(dir, basename) 29 | next if ignored_path?(abspath) 30 | 31 | if dir?(abspath) 32 | next if root_dirs.include?(abspath) 33 | next if !has_at_least_one_ruby_file?(abspath) 34 | else 35 | next unless ruby?(abspath) 36 | end 37 | 38 | # We freeze abspath because that saves allocations when passed later to 39 | # File methods. See #125. 40 | yield basename, abspath.freeze 41 | end 42 | end 43 | 44 | # @sig (String) -> bool 45 | private def has_at_least_one_ruby_file?(dir) 46 | to_visit = [dir] 47 | 48 | while dir = to_visit.shift 49 | ls(dir) do |_basename, abspath| 50 | if dir?(abspath) 51 | to_visit << abspath 52 | else 53 | return true 54 | end 55 | end 56 | end 57 | 58 | false 59 | end 60 | 61 | # @sig (String) -> bool 62 | private def ruby?(path) 63 | path.end_with?(".rb") 64 | end 65 | 66 | # @sig (String) -> bool 67 | private def dir?(path) 68 | File.directory?(path) 69 | end 70 | 71 | # @sig (String) -> bool 72 | private def hidden?(basename) 73 | basename.start_with?(".") 74 | end 75 | 76 | # @sig (String) { (String) -> void } -> void 77 | private def walk_up(abspath) 78 | loop do 79 | yield abspath 80 | abspath, basename = File.split(abspath) 81 | break if basename == "/" 82 | end 83 | end 84 | 85 | # --- Constants --------------------------------------------------------------------------------- 86 | 87 | # The autoload? predicate takes into account the ancestor chain of the 88 | # receiver, like const_defined? and other methods in the constants API do. 89 | # 90 | # For example, given 91 | # 92 | # class A 93 | # autoload :X, "x.rb" 94 | # end 95 | # 96 | # class B < A 97 | # end 98 | # 99 | # B.autoload?(:X) returns "x.rb". 100 | # 101 | # We need a way to strictly check in parent ignoring ancestors. 102 | # 103 | # @sig (Module, Symbol) -> String? 104 | private def strict_autoload_path(parent, cname) 105 | parent.autoload?(cname, false) 106 | end 107 | 108 | # @sig (Module, Symbol) -> String 109 | private def cpath(parent, cname) 110 | Object == parent ? cname.name : "#{Im.cpath(parent)}::#{cname.name}" 111 | end 112 | 113 | # @sig (Module, Symbol) -> bool 114 | private def cdef?(parent, cname) 115 | parent.const_defined?(cname, false) 116 | end 117 | 118 | # @raise [NameError] 119 | # @sig (Module, Symbol) -> Object 120 | private def cget(parent, cname) 121 | parent.const_get(cname, false) 122 | end 123 | 124 | # @raise [NameError] 125 | # @sig (Module, Symbol) -> Object 126 | private def crem(parent, cname) 127 | parent.__send__(:remove_const, cname) 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /test/lib/im/test_autovivification.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class TestAutovivification < LoaderTest 6 | test "autoloads a simple constant in an autovivified module (Object)" do 7 | files = [["admin/x.rb", "Admin::X = true"]] 8 | with_setup(files) do 9 | assert_kind_of Module, loader::Admin 10 | assert loader::Admin::X 11 | end 12 | end 13 | 14 | test "autovivifies several levels in a row (Object)" do 15 | files = [["foo/bar/baz/woo.rb", "Foo::Bar::Baz::Woo = true"]] 16 | with_setup(files) do 17 | assert loader::Foo::Bar::Baz::Woo 18 | end 19 | end 20 | 21 | test "autoloads several constants from the same namespace (Object)" do 22 | files = [ 23 | ["rd1/admin/hotel.rb", "class Admin::Hotel; end"], 24 | ["rd2/admin/hotels_controller.rb", "class Admin::HotelsController; end"] 25 | ] 26 | with_setup(files) do 27 | assert loader::Admin::Hotel 28 | assert loader::Admin::HotelsController 29 | end 30 | end 31 | 32 | test "does not register the namespace as explicit" do 33 | files = [ 34 | ["rd1/admin/x.rb", "Admin::X = true"], 35 | ["rd2/admin/y.rb", "Admin::Y = true"] 36 | ] 37 | with_setup(files) do 38 | assert !Im::ExplicitNamespace.__registered?("Admin") 39 | end 40 | end 41 | 42 | test "autovivification is synchronized" do 43 | $test_admin_const_set_calls = 0 44 | $test_admin_const_set_queue = Queue.new 45 | 46 | files = [["admin/v2/user.rb", "class Admin::V2::User; end"]] 47 | with_setup(files) do 48 | assert loader::Admin 49 | 50 | loader_admin = loader::Admin 51 | def loader_admin.const_set(cname, mod) 52 | $test_admin_const_set_calls += 1 53 | $test_admin_const_set_queue << true 54 | sleep 0.1 55 | super 56 | end 57 | 58 | concurrent_autovivifications = [ 59 | Thread.new { 60 | loader::Admin::V2 61 | }, 62 | Thread.new { 63 | $test_admin_const_set_queue.pop() 64 | loader::Admin::V2 65 | } 66 | ] 67 | 68 | concurrent_autovivifications.each(&:join) 69 | 70 | assert $test_admin_const_set_queue.empty? 71 | assert_equal 1, $test_admin_const_set_calls 72 | end 73 | end 74 | 75 | test "defines no namespace for empty directories" do 76 | with_files([]) do 77 | FileUtils.mkdir("foo") 78 | loader.push_dir(".") 79 | loader.setup 80 | assert !loader.autoload?(:Foo) 81 | end 82 | end 83 | 84 | test "defines no namespace for empty directories (recursively)" do 85 | with_files([]) do 86 | FileUtils.mkdir_p("foo/bar/baz") 87 | loader.push_dir(".") 88 | loader.setup 89 | assert !loader.autoload?(:Foo) 90 | end 91 | end 92 | 93 | test "defines no namespace for directories whose files are all non-Ruby" do 94 | with_setup([["tasks/newsletter.rake", ""], ["assets/.keep", ""]]) do 95 | assert !loader.autoload?(:Tasks) 96 | assert !loader.autoload?(:Assets) 97 | end 98 | end 99 | 100 | test "defines no namespace for directories whose files are all non-Ruby (recursively)" do 101 | with_setup([["tasks/product/newsletter.rake", ""], ["assets/css/.keep", ""]]) do 102 | assert !loader.autoload?(:Tasks) 103 | assert !loader.autoload?(:Assets) 104 | end 105 | end 106 | 107 | test "defines no namespace for directories whose Ruby files are all ignored" do 108 | with_setup([["foo/bar/ignored.rb", "IGNORED"]]) do 109 | assert !loader.autoload?(:Foo) 110 | end 111 | end 112 | 113 | test "defines no namespace for directories that have Ruby files below ignored directories" do 114 | with_setup([["foo/ignored/baz.rb", "IGNORED"]]) do 115 | assert !loader.autoload?(:Foo) 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /lib/im/explicit_namespace.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Im 4 | # Centralizes the logic for the trace point used to detect the creation of 5 | # explicit namespaces, needed to descend into matching subdirectories right 6 | # after the constant has been defined. 7 | # 8 | # The implementation assumes an explicit namespace is managed by one loader. 9 | # Loaders that reopen namespaces owned by other projects are responsible for 10 | # loading their constant before setup. This is documented. 11 | module ExplicitNamespace # :nodoc: all 12 | class << self 13 | extend Internal 14 | 15 | # Maps constant paths that correspond to explicit namespaces according to 16 | # the file system, to the loader responsible for them. 17 | # 18 | # @sig Hash[String, [String, Im::Loader]] 19 | attr_reader :cpaths 20 | private :cpaths 21 | 22 | # @sig Mutex 23 | attr_reader :mutex 24 | private :mutex 25 | 26 | # @sig TracePoint 27 | attr_reader :tracer 28 | private :tracer 29 | 30 | # Asserts `cpath` corresponds to an explicit namespace for which `loader` 31 | # is responsible. 32 | # 33 | # @sig (String, Im::Loader) -> void 34 | internal def register(cpath, module_name, loader) 35 | mutex.synchronize do 36 | cpaths[cpath] = [module_name, loader] 37 | # We check enabled? because, looking at the C source code, enabling an 38 | # enabled tracer does not seem to be a simple no-op. 39 | tracer.enable unless tracer.enabled? 40 | end 41 | end 42 | 43 | # @sig (Im::Loader) -> void 44 | internal def unregister_loader(loader) 45 | cpaths.delete_if { |_cpath, (_, l)| l == loader } 46 | disable_tracer_if_unneeded 47 | end 48 | 49 | # This is an internal method only used by the test suite. 50 | # 51 | # @sig (String) -> bool 52 | internal def registered?(cpath) 53 | cpaths.key?(cpath) 54 | end 55 | 56 | # @sig (String, String) -> void 57 | internal def update_cpaths(prefix, replacement) 58 | pattern = /^#{prefix}/ 59 | mutex.synchronize do 60 | cpaths.transform_keys! do |key| 61 | key.start_with?(prefix) ? key.gsub(pattern, replacement) : key 62 | end 63 | end 64 | end 65 | 66 | # @sig () -> void 67 | private def disable_tracer_if_unneeded 68 | mutex.synchronize do 69 | tracer.disable if cpaths.empty? 70 | end 71 | end 72 | 73 | # @sig (TracePoint) -> void 74 | private def tracepoint_class_callback(event) 75 | # If the class is a singleton class, we won't do anything with it so we 76 | # can bail out immediately. This is several orders of magnitude faster 77 | # than accessing its name. 78 | return if event.self.singleton_class? 79 | 80 | # It might be tempting to return if name.nil?, to avoid the computation 81 | # of a hash code and delete call. But Ruby does not trigger the :class 82 | # event on Class.new or Module.new, so that would incur in an extra call 83 | # for nothing. 84 | # 85 | # On the other hand, if we were called, cpaths is not empty. Otherwise 86 | # the tracer is disabled. So we do need to go ahead with the hash code 87 | # computation and delete call. 88 | relative_cpath, loader = cpaths.delete(Im.cpath(event.self)) 89 | if loader 90 | loader.on_namespace_loaded(relative_cpath) 91 | disable_tracer_if_unneeded 92 | end 93 | end 94 | end 95 | 96 | @cpaths = {} 97 | @mutex = Mutex.new 98 | 99 | # We go through a method instead of defining a block mainly to have a better 100 | # label when profiling. 101 | @tracer = TracePoint.new(:class, &method(:tracepoint_class_callback)) 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /test/lib/im/test_collapse.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "set" 5 | 6 | class TestCollapse < LoaderTest 7 | test "top-level directories can be collapsed" do 8 | files = [["collapsed/bar/x.rb", "Bar::X = true"]] 9 | with_setup(files) do 10 | assert loader::Bar::X 11 | end 12 | end 13 | 14 | test "collapsed directories are ignored as namespaces" do 15 | files = [["foo/collapsed/x.rb", "Foo::X = true"]] 16 | with_setup(files) do 17 | assert loader::Foo::X 18 | end 19 | end 20 | 21 | test "collapsed directories are ignored as explicit namespaces" do 22 | files = [ 23 | ["collapsed.rb", "Collapsed = true"], 24 | ["collapsed/x.rb", "X = true"] 25 | ] 26 | with_setup(files) do 27 | assert loader::Collapsed 28 | assert loader::X 29 | end 30 | end 31 | 32 | test "explicit namespaces are honored downwards" do 33 | files = [ 34 | ["foo.rb", "module Foo; end"], 35 | ["foo/foo/x.rb", "Foo::X = true"] 36 | ] 37 | with_files(files) do 38 | loader.push_dir(".") 39 | loader.collapse("foo") 40 | loader.setup 41 | 42 | assert loader::Foo::X 43 | end 44 | end 45 | 46 | test "explicit namespaces are honored downwards, deeper" do 47 | files = [ 48 | ["foo.rb", "module Foo; end"], 49 | ["foo/bar/foo/x.rb", "Foo::X = true"] 50 | ] 51 | with_files(files) do 52 | loader.push_dir(".") 53 | loader.collapse(["foo", "foo/bar"]) 54 | loader.setup 55 | 56 | assert loader::Foo::X 57 | end 58 | end 59 | 60 | test "accepts several arguments" do 61 | files = [ 62 | ["foo/bar/x.rb", "Foo::X = true"], 63 | ["zoo/bar/x.rb", "Zoo::X = true"] 64 | ] 65 | with_files(files) do 66 | loader.push_dir(".") 67 | loader.collapse("foo/bar", "zoo/bar") 68 | loader.setup 69 | 70 | assert loader::Foo::X 71 | assert loader::Zoo::X 72 | end 73 | end 74 | 75 | test "accepts an array" do 76 | files = [ 77 | ["foo/bar/x.rb", "Foo::X = true"], 78 | ["zoo/bar/x.rb", "Zoo::X = true"] 79 | ] 80 | with_files(files) do 81 | loader.push_dir(".") 82 | loader.collapse(["foo/bar", "zoo/bar"]) 83 | loader.setup 84 | 85 | assert loader::Foo::X 86 | assert loader::Zoo::X 87 | end 88 | end 89 | 90 | test "supports glob patterns" do 91 | files = [ 92 | ["foo/bar/x.rb", "Foo::X = true"], 93 | ["zoo/bar/x.rb", "Zoo::X = true"] 94 | ] 95 | with_files(files) do 96 | loader.push_dir(".") 97 | loader.collapse("*/bar") 98 | loader.setup 99 | 100 | assert loader::Foo::X 101 | assert loader::Zoo::X 102 | end 103 | end 104 | 105 | test "collapse glob patterns are recomputed on reload" do 106 | files = [["foo/bar/x.rb", "Foo::X = true"]] 107 | with_files(files) do 108 | loader.push_dir(".") 109 | loader.collapse("*/bar") 110 | loader.setup 111 | 112 | assert loader::Foo::X 113 | assert_raises(NameError) { loader::Zoo::X } 114 | 115 | FileUtils.mkdir_p("zoo/bar") 116 | File.write("zoo/bar/x.rb", "Zoo::X = true") 117 | 118 | loader.reload 119 | 120 | assert loader::Foo::X 121 | assert loader::Zoo::X 122 | end 123 | end 124 | 125 | test "collapse directories are honored when eager loading" do 126 | $collapse_honored_when_eager_loading = false 127 | files = [["foo/collapsed/x.rb", "Foo::X = true"]] 128 | with_setup(files) do 129 | loader.eager_load 130 | assert required?(files) 131 | end 132 | end 133 | 134 | test "collapsed top-level directories are eager loaded too" do 135 | $collapse_honored_when_eager_loading = false 136 | files = [["collapsed/bar/x.rb", "Bar::X = true"]] 137 | with_setup(files) do 138 | loader.eager_load 139 | assert required?(files) 140 | end 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /lib/im/loader/callbacks.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Im::Loader::Callbacks 4 | # Invoked from our decorated Kernel#require when a managed file is autoloaded. 5 | # 6 | # @private 7 | # @sig (String) -> void 8 | def on_file_autoloaded(file) 9 | cref = autoloads.delete(file) 10 | 11 | relative_cpath = relative_cpath(*cref) 12 | Im::Registry.unregister_autoload(file) 13 | 14 | if cdef?(*cref) 15 | obj = cget(*cref) 16 | if obj.is_a?(Module) 17 | register_module_name(obj, relative_cpath) 18 | Im::Registry.register_autoloaded_module(get_object_hash(obj), relative_cpath, self) 19 | end 20 | log("constant #{relative_cpath} loaded from file #{file}") if logger 21 | to_unload[relative_cpath] = [file, cref] if reloading_enabled? 22 | run_on_load_callbacks(relative_cpath, obj, file) unless on_load_callbacks.empty? 23 | else 24 | msg = "expected file #{file} to define constant #{cpath(*cref)}, but didn't" 25 | log(msg) if logger 26 | crem(*cref) 27 | to_unload[relative_cpath] = [file, cref] if reloading_enabled? 28 | raise Im::NameError.new(msg, cref.last) 29 | end 30 | end 31 | 32 | # Invoked from our decorated Kernel#require when a managed directory is 33 | # autoloaded. 34 | # 35 | # @private 36 | # @sig (String) -> void 37 | def on_dir_autoloaded(dir) 38 | # Module#autoload does not serialize concurrent requires, and we handle 39 | # directories ourselves, so the callback needs to account for concurrency. 40 | # 41 | # Multi-threading would introduce a race condition here in which thread t1 42 | # autovivifies the module, and while autoloads for its children are being 43 | # set, thread t2 autoloads the same namespace. 44 | # 45 | # Without the mutex and subsequent delete call, t2 would reset the module. 46 | # That not only would reassign the constant (undesirable per se) but, worse, 47 | # the module object created by t2 wouldn't have any of the autoloads for its 48 | # children, since t1 would have correctly deleted its namespace_dirs entry. 49 | mutex2.synchronize do 50 | if cref = autoloads.delete(dir) 51 | autovivified_module = cref[0].const_set(cref[1], Module.new) 52 | relative_cpath = relative_cpath(*cref) 53 | register_module_name(autovivified_module, relative_cpath) 54 | Im::Registry.register_autoloaded_module(autovivified_module.hash, relative_cpath, self) 55 | log("module #{relative_cpath} autovivified from directory #{dir}") if logger 56 | 57 | to_unload[relative_cpath] = [dir, cref] if reloading_enabled? 58 | 59 | # We don't unregister `dir` in the registry because concurrent threads 60 | # wouldn't find a loader associated to it in Kernel#require and would 61 | # try to require the directory. Instead, we are going to keep track of 62 | # these to be able to unregister later if eager loading. 63 | autoloaded_dirs << dir 64 | 65 | on_namespace_loaded(relative_cpath) 66 | 67 | run_on_load_callbacks(relative_cpath, autovivified_module, dir) unless on_load_callbacks.empty? 68 | end 69 | end 70 | end 71 | 72 | # Invoked when a class or module is created or reopened, either from the 73 | # tracer or from module autovivification. If the namespace has matching 74 | # subdirectories, we descend into them now. 75 | # 76 | # @private 77 | # @sig (Module) -> void 78 | def on_namespace_loaded(module_name) 79 | if dirs = namespace_dirs.delete(module_name) 80 | dirs.each do |dir| 81 | set_autoloads_in_dir(dir, cget(self, module_name)) 82 | end 83 | end 84 | end 85 | 86 | private 87 | 88 | # @sig (String, Object) -> void 89 | def run_on_load_callbacks(cpath, value, abspath) 90 | # Order matters. If present, run the most specific one. 91 | callbacks = reloading_enabled? ? on_load_callbacks[cpath] : on_load_callbacks.delete(cpath) 92 | callbacks&.each { |c| c.call(value, abspath) } 93 | 94 | callbacks = on_load_callbacks[:ANY] 95 | callbacks&.each { |c| c.call(cpath, value, abspath) } 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /test/lib/im/test_unload.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class TestUnload < LoaderTest 6 | test "unload removes all autoloaded constants" do 7 | files = [ 8 | ["user.rb", "class User; end"], 9 | ["admin/root.rb", "class Admin::Root; end"] 10 | ] 11 | with_setup(files) do 12 | assert loader::User 13 | assert loader::Admin::Root 14 | admin = loader::Admin 15 | 16 | loader.unload 17 | 18 | assert !loader.const_defined?(:User) 19 | assert !loader.const_defined?(:Admin) 20 | assert !admin.const_defined?(:Root) 21 | end 22 | end 23 | 24 | test "unload removes autoloaded constants, even if #name is overridden" do 25 | files = [["x.rb", <<~RUBY]] 26 | module X 27 | def self.name 28 | "Y" 29 | end 30 | end 31 | RUBY 32 | with_setup(files) do 33 | assert loader::X 34 | loader.unload 35 | assert !loader.const_defined?(:X) 36 | end 37 | end 38 | 39 | test "unload removes non-executed autoloads" do 40 | files = [["x.rb", "X = true"]] 41 | with_setup(files) do 42 | # This does not autolaod, see the compatibility test. 43 | assert loader.const_defined?(:X) 44 | loader.unload 45 | assert !loader.const_defined?(:X) 46 | end 47 | end 48 | 49 | test "unload clears internal caches" do 50 | files = [ 51 | ["rd1/user.rb", "class User; end"], 52 | ["rd1/api/v1/users_controller.rb", "class Api::V1::UsersController; end"], 53 | ["rd1/admin/root.rb", "class Admin::Root; end"], 54 | ["rd2/user.rb", "class User; end"] 55 | ] 56 | with_setup(files) do 57 | assert loader::User 58 | assert loader::Api::V1::UsersController 59 | 60 | assert !loader.__autoloads.empty? 61 | assert !loader.__autoloaded_dirs.empty? 62 | assert !loader.__to_unload.empty? 63 | assert !loader.__namespace_dirs.empty? 64 | 65 | loader.unload 66 | 67 | assert loader.__autoloads.empty? 68 | assert loader.__autoloaded_dirs.empty? 69 | assert loader.__to_unload.empty? 70 | assert loader.__namespace_dirs.empty? 71 | end 72 | end 73 | 74 | test "unload does not assume autoloaded constants are still there" do 75 | files = [["x.rb", "X = true"]] 76 | with_setup(files) do 77 | assert loader::X 78 | assert loader.send(:remove_const, :X) # user removed the constant by hand 79 | loader.unload # should not raise 80 | end 81 | end 82 | 83 | test "already existing namespaces are not reset" do 84 | on_teardown do 85 | delete_loaded_feature "active_storage.rb" 86 | end 87 | 88 | files = [ 89 | ["app/models/active_storage/blob.rb", "class ActiveStorage::Blob; end"] 90 | ] 91 | with_files(files) do 92 | with_load_path("lib") do 93 | loader::ActiveStorage = Module.new 94 | 95 | loader.push_dir("app/models") 96 | loader.setup 97 | 98 | assert loader::ActiveStorage::Blob 99 | loader.unload 100 | assert loader::ActiveStorage 101 | end 102 | end 103 | end 104 | 105 | test "unload clears explicit namespaces associated" do 106 | files = [ 107 | ["a/m.rb", "module M; end"], ["a/m/n.rb", "M::N = true"], 108 | ["b/x.rb", "module X; end"], ["b/x/y.rb", "X::Y = true"], 109 | ] 110 | with_files(files) do 111 | la = new_loader(dirs: "a") 112 | assert Im::ExplicitNamespace.send(:cpaths)["#{la}::M"] == ["M", la] 113 | 114 | lb = new_loader(dirs: "b") 115 | assert Im::ExplicitNamespace.send(:cpaths)["#{lb}::X"] == ["X", lb] 116 | 117 | la.unload 118 | assert_nil Im::ExplicitNamespace.send(:cpaths)["#{la}::M"] 119 | assert Im::ExplicitNamespace.send(:cpaths)["#{lb}::X"] == ["X", lb] 120 | end 121 | end 122 | 123 | test "unload clears the set of shadowed files" do 124 | files = [ 125 | ["a/m.rb", "module M; end"], 126 | ["b/m.rb", "module M; end"], 127 | ] 128 | with_files(files) do 129 | loader.push_dir("a") 130 | loader.push_dir("b") 131 | loader.setup 132 | 133 | assert !loader.__shadowed_files.empty? # precondition 134 | loader.unload 135 | assert loader.__shadowed_files.empty? 136 | end 137 | end 138 | 139 | test "unload clears state even if the autoload failed and the exception was rescued" do 140 | files = [["x.rb", "X_IS_NOT_DEFINED = true"]] 141 | with_setup(files) do 142 | begin 143 | loader::X 144 | rescue Im::NameError 145 | pass # precondition holds 146 | else 147 | flunk # precondition failed 148 | end 149 | 150 | loader.unload 151 | 152 | assert !loader.constants.include?(:X) 153 | assert !required?(files) 154 | end 155 | end 156 | 157 | test "raises if called before setup" do 158 | assert_raises(Im::SetupRequired) do 159 | loader.unload 160 | end 161 | end 162 | end 163 | -------------------------------------------------------------------------------- /test/lib/im/test_on_unload.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class TestOnUnload < LoaderTest 6 | test "on_unload checks its argument type" do 7 | assert_raises(TypeError, "on_unload only accepts strings") do 8 | loader.on_unload(:X) {} 9 | end 10 | 11 | assert_raises(TypeError, "on_unload only accepts strings") do 12 | loader.on_unload(Object) {} 13 | end 14 | end 15 | 16 | test "multiple on_unload on cpaths are called in order of definition" do 17 | with_setup([["x.rb", "X = 1"]]) do 18 | x = [] 19 | loader.on_unload("X") { x << 1 } 20 | loader.on_unload("X") { x << 2 } 21 | 22 | assert loader::X 23 | loader.reload 24 | 25 | assert_equal [1, 2], x 26 | end 27 | end 28 | 29 | test "on_unload blocks for cpaths get the expected arguments passed" do 30 | with_setup([["x.rb", "X = 1"]]) do 31 | args = []; loader.on_unload("X") { |*a| args = a } 32 | 33 | assert loader::X 34 | loader.reload 35 | 36 | assert_equal 1, args[0] 37 | assert_abspath "x.rb", args[1] 38 | end 39 | end 40 | 41 | test "on_unload for cpaths is called before the constant is removed" do 42 | with_setup([["x.rb", "X = 1"]]) do 43 | defined_X = false 44 | loader.on_unload("X") { defined_X = loader.const_defined?(:X) } 45 | 46 | assert loader::X 47 | loader.reload 48 | 49 | assert defined_X 50 | end 51 | end 52 | 53 | test "on_unload on cpaths is not called for other constants" do 54 | files = [ 55 | ["x.rb", "X = 1"], 56 | ["y.rb", "Y = 1"] 57 | ] 58 | with_setup(files) do 59 | on_unload_for_Y = false 60 | loader.on_unload("Y") { on_unload_for_Y = true } 61 | 62 | assert loader::X 63 | loader.reload 64 | 65 | assert !on_unload_for_Y 66 | end 67 | end 68 | 69 | test "on_unload on cpaths is resilient to manually removed constants" do 70 | with_setup([["x.rb", "X = 1"]]) do 71 | on_unload_for_X = false 72 | loader.on_unload("X") { on_unload_for_X = true } 73 | 74 | assert loader::X 75 | loader.send(:remove_const, :X) 76 | loader.reload 77 | 78 | assert !on_unload_for_X 79 | end 80 | end 81 | 82 | test "on_unload on cpaths is resilient to failed autoloads" do 83 | with_setup([["x.rb", "Y = 1"]]) do 84 | on_unload_for_X = false 85 | loader.on_unload("X") { on_unload_for_X = true } 86 | 87 | assert_raises(Im::NameError) { loader::X } 88 | loader.reload 89 | 90 | assert !on_unload_for_X 91 | end 92 | end 93 | 94 | test "on_unload on cpaths does not trigger a failed autoload twice" do 95 | $failed_autoloads = 0 96 | with_setup([["x.rb", "$failed_autoloads += 1; Y = 1"]]) do 97 | loader.on_unload("X") {} 98 | 99 | assert_raises(Im::NameError) { loader::X } 100 | loader.reload 101 | 102 | assert_equal 1, $failed_autoloads 103 | end 104 | end 105 | 106 | test "on_unload for :ANY is called with the expected arguments" do 107 | with_setup([["x.rb", "X = 1"]]) do 108 | args = []; loader.on_unload { |*a| args << a } 109 | 110 | assert loader::X 111 | loader.reload 112 | 113 | assert_equal 1, args.length 114 | assert_equal "X", args[0][0] 115 | assert_equal 1, args[0][1] 116 | assert_abspath "x.rb", args[0][2] 117 | end 118 | end 119 | 120 | test "on_unload for :ANY is called before the constant is removed" do 121 | with_setup([["x.rb", "X = 1"]]) do 122 | defined_X = false 123 | loader.on_unload { defined_X = loader.const_defined?(:X) } 124 | 125 | assert loader::X 126 | loader.reload 127 | 128 | assert defined_X 129 | end 130 | end 131 | 132 | test "multiple on_unload for :ANY are called in order of definition" do 133 | with_setup([["x.rb", "X = 1"]]) do 134 | x = [] 135 | loader.on_unload { x << 1 } 136 | loader.on_unload { x << 2 } 137 | 138 | assert loader::X 139 | loader.reload 140 | 141 | assert_equal [1, 2], x 142 | end 143 | end 144 | 145 | test "if there are specific and :ANY on_unloads, the specific one runs first" do 146 | with_setup([["x.rb", "X = 1"]]) do 147 | x = [] 148 | loader.on_unload { x << 2 } 149 | loader.on_unload("X") { x << 1 } 150 | 151 | assert loader::X 152 | loader.reload 153 | 154 | assert_equal [1, 2], x 155 | end 156 | end 157 | 158 | test "on_unload for :ANY is is resilient to manually removed constants" do 159 | with_setup([["x.rb", "X = 1"]]) do 160 | on_unload_for_X = false 161 | loader.on_unload { on_unload_for_X = true } 162 | 163 | assert loader::X 164 | loader.send(:remove_const, :X) 165 | loader.reload 166 | 167 | assert !on_unload_for_X 168 | end 169 | end 170 | 171 | test "on_unload for :ANY is is resilient to failed autoloads" do 172 | with_setup([["x.rb", "Y = 1"]]) do 173 | on_unload_for_X = false 174 | loader.on_unload { on_unload_for_X = true } 175 | 176 | assert_raises(Im::NameError) { loader::X } 177 | loader.reload 178 | 179 | assert !on_unload_for_X 180 | end 181 | end 182 | 183 | test "on_unload on :ANY does not trigger a failed autoload twice" do 184 | $failed_autoloads = 0 185 | with_setup([["x.rb", "$failed_autoloads += 1; Y = 1"]]) do 186 | loader.on_unload {} 187 | 188 | assert_raises(Im::NameError) { loader::X } 189 | loader.reload 190 | 191 | assert_equal 1, $failed_autoloads 192 | end 193 | end 194 | end 195 | -------------------------------------------------------------------------------- /test/lib/im/test_ignore.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "set" 5 | 6 | class TestIgnore < LoaderTest 7 | def this_dir 8 | @this_dir ||= __dir__ 9 | end 10 | 11 | def this_file 12 | @this_file ||= File.expand_path(__FILE__, this_dir) 13 | end 14 | 15 | def ascendant 16 | @dir_up ||= File.expand_path("#{this_dir}/../..") 17 | end 18 | 19 | test "ignored root directories are ignored" do 20 | files = [["x.rb", "X = true"]] 21 | with_files(files) do 22 | loader.push_dir(".") 23 | loader.ignore(".") 24 | loader.setup 25 | 26 | assert !loader.autoload?(:X) 27 | assert_raises(NameError) { loader::X } 28 | end 29 | end 30 | 31 | test "ignored root directories are ignored, but nested ones are not" do 32 | files = [["x.rb", "X = true"], ["nested/y.rb", "Y = true"]] 33 | with_files(files) do 34 | loader.push_dir(".") 35 | loader.push_dir("nested") 36 | loader.ignore(".") 37 | loader.setup 38 | 39 | assert !loader.autoload?(:X) 40 | assert_raises(NameError) { loader::X } 41 | assert loader::Y 42 | end 43 | end 44 | 45 | test "ignored files are ignored" do 46 | files = [ 47 | ["x.rb", "X = true"], 48 | ["y.rb", "Y = true"] 49 | ] 50 | with_files(files) do 51 | loader.push_dir(".") 52 | loader.ignore("y.rb") 53 | loader.setup 54 | 55 | assert loader.autoload?(:X) 56 | assert !loader.autoload?(:Y) 57 | 58 | assert loader::X 59 | assert_raises(NameError) { loader::Y } 60 | end 61 | end 62 | 63 | test "ignored directories are ignored" do 64 | files = [ 65 | ["x.rb", "X = true"], 66 | ["m/a.rb", "M::A = true"], 67 | ["m/b.rb", "M::B = true"], 68 | ["m/c.rb", "M::C = true"] 69 | ] 70 | with_files(files) do 71 | loader.push_dir(".") 72 | loader.ignore("m") 73 | loader.setup 74 | 75 | assert loader.autoload?(:X) 76 | assert !loader.autoload?(:M) 77 | 78 | assert loader::X 79 | assert_raises(NameError) { loader::M } 80 | end 81 | end 82 | 83 | test "ignored files are not eager loaded" do 84 | files = [ 85 | ["x.rb", "X = true"], 86 | ["y.rb", "Y = true"] 87 | ] 88 | with_files(files) do 89 | loader.push_dir(".") 90 | loader.ignore("y.rb") 91 | loader.setup 92 | loader.eager_load 93 | 94 | assert loader::X 95 | assert_raises(NameError) { loader::Y } 96 | end 97 | end 98 | 99 | test "ignored directories are not eager loaded" do 100 | files = [ 101 | ["x.rb", "X = true"], 102 | ["m/a.rb", "M::A = true"], 103 | ["m/b.rb", "M::B = true"], 104 | ["m/c.rb", "M::C = true"] 105 | ] 106 | with_files(files) do 107 | loader.push_dir(".") 108 | loader.ignore("m") 109 | loader.setup 110 | loader.eager_load 111 | 112 | assert loader::X 113 | assert_raises(NameError) { loader::M } 114 | end 115 | end 116 | 117 | test "supports several arguments" do 118 | a = "#{Dir.pwd}/a.rb" 119 | b = "#{Dir.pwd}/b.rb" 120 | loader.ignore(a, b) 121 | assert_equal [a, b].to_set, loader.send(:ignored_glob_patterns) 122 | end 123 | 124 | test "supports an array" do 125 | a = "#{Dir.pwd}/a.rb" 126 | b = "#{Dir.pwd}/b.rb" 127 | loader.ignore([a, b]) 128 | assert_equal [a, b].to_set, loader.send(:ignored_glob_patterns) 129 | end 130 | 131 | test "supports glob patterns" do 132 | files = [ 133 | ["admin/user.rb", "class Admin::User; end"], 134 | ["admin/user_test.rb", "class Admin::UserTest < Minitest::Test; end"] 135 | ] 136 | with_files(files) do 137 | loader.push_dir(".") 138 | loader.ignore("**/*_test.rb") 139 | loader.setup 140 | 141 | assert loader::Admin::User 142 | assert_raises(NameError) { loader::Admin::UserTest } 143 | end 144 | end 145 | 146 | test "ignored paths are recomputed on reload" do 147 | files = [ 148 | ["user.rb", "class User; end"], 149 | ["user_test.rb", "class UserTest < Minitest::Test; end"], 150 | ] 151 | with_files(files) do 152 | loader.push_dir(".") 153 | loader.ignore("*_test.rb") 154 | loader.setup 155 | 156 | assert loader::User 157 | assert_raises(NameError) { loader::UserTest } 158 | 159 | File.write("post.rb", "class Post; end") 160 | File.write("post_test.rb", "class PostTest < Minitest::Test; end") 161 | 162 | loader.reload 163 | 164 | assert loader::Post 165 | assert_raises(NameError) { loader::PostTest } 166 | end 167 | end 168 | 169 | test "returns true if a directory is ignored as is" do 170 | loader.ignore(this_dir) 171 | assert loader.__ignores?(this_dir) 172 | end 173 | 174 | test "returns true if a file is ignored as is" do 175 | loader.ignore(this_file) 176 | assert loader.__ignores?(this_file) 177 | end 178 | 179 | test "returns true for a descendant of an ignored directory" do 180 | loader.ignore(ascendant) 181 | assert loader.__ignores?(this_dir) 182 | end 183 | 184 | test "returns true for a file in a descendant of an ignored directory" do 185 | loader.ignore(ascendant) 186 | assert loader.__ignores?(this_file) 187 | end 188 | 189 | test "returns false for the directory of an ignored file" do 190 | loader.ignore(this_file) 191 | assert !loader.__ignores?(this_dir) 192 | end 193 | 194 | test "returns false for an ascendant directory of an ignored directory" do 195 | loader.ignore(this_dir) 196 | assert !loader.__ignores?(ascendant) 197 | end 198 | 199 | test "returns false if nothing is ignored" do 200 | assert !loader.__ignores?(this_dir) 201 | assert !loader.__ignores?(this_file) 202 | end 203 | end 204 | -------------------------------------------------------------------------------- /lib/im/registry.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Im 4 | module Registry # :nodoc: all 5 | class << self 6 | # Keeps track of all loaders. Useful to broadcast messages and to prevent 7 | # them from being garbage collected. 8 | # 9 | # @private 10 | # @sig Array[Im::Loader] 11 | attr_reader :loaders 12 | 13 | # Registers gem loaders to let `for_gem` be idempotent in case of reload. 14 | # 15 | # @private 16 | # @sig Hash[String, Im::Loader] 17 | attr_reader :gem_loaders_by_root_file 18 | 19 | # Maps absolute paths to the loaders responsible for them. 20 | # 21 | # This information is used by our decorated `Kernel#require` to be able to 22 | # invoke callbacks and autovivify modules. 23 | # 24 | # @private 25 | # @sig Hash[String, Im::Loader] 26 | attr_reader :autoloads 27 | 28 | # @private 29 | # @sig Hash[String, Im::Loader] 30 | attr_reader :paths 31 | 32 | # This hash table addresses an edge case in which an autoload is ignored. 33 | # 34 | # For example, let's suppose we want to autoload in a gem like this: 35 | # 36 | # # lib/my_gem.rb 37 | # loader = Im::Loader.new 38 | # loader.push_dir(__dir__) 39 | # loader.setup 40 | # 41 | # module loader::MyGem 42 | # end 43 | # 44 | # if you require "my_gem", as Bundler would do, this happens while setting 45 | # up autoloads: 46 | # 47 | # 1. Object.autoload?(:MyGem) returns `nil` because the autoload for 48 | # the constant is issued by Im while the same file is being 49 | # required. 50 | # 2. The constant `MyGem` is undefined while setup runs. 51 | # 52 | # Therefore, a directory `lib/my_gem` would autovivify a module according to 53 | # the existing information. But that would be wrong. 54 | # 55 | # To overcome this fundamental limitation, we keep track of the constant 56 | # paths that are in this situation ---in the example above, "MyGem"--- and 57 | # take this collection into account for the autovivification logic. 58 | # 59 | # Note that you cannot generally address this by moving the setup code 60 | # below the constant definition, because we want libraries to be able to 61 | # use managed constants in the module body: 62 | # 63 | # module loader::MyGem 64 | # include MyConcern 65 | # end 66 | # 67 | # @private 68 | # @sig Hash[String, [String, Im::Loader]] 69 | attr_reader :inceptions 70 | 71 | # @private 72 | # @sig Hash[Integer, [Im::Loader, String, Array]] 73 | attr_reader :autoloaded_modules 74 | 75 | # Registers a loader. 76 | # 77 | # @private 78 | # @sig (Im::Loader) -> void 79 | def register_loader(loader) 80 | loaders << loader 81 | end 82 | 83 | # @private 84 | # @sig (Im::Loader) -> void 85 | def unregister_loader(loader) 86 | loaders.delete(loader) 87 | gem_loaders_by_root_file.delete_if { |_, l| l == loader } 88 | autoloads.delete_if { |_, l| l == loader } 89 | paths.delete_if { |_, l| l == loader } 90 | inceptions.delete_if { |_, (_, l)| l == loader } 91 | autoloaded_modules.delete_if { |_, (_, l, _)| l == loader } 92 | end 93 | 94 | # This method returns always a loader, the same instance for the same root 95 | # file. That is how Im::Loader.for_gem is idempotent. 96 | # 97 | # @private 98 | # @sig (String) -> Im::Loader 99 | def loader_for_gem(root_file, warn_on_extra_files:) 100 | gem_loaders_by_root_file[root_file] ||= GemLoader._new(root_file, warn_on_extra_files: warn_on_extra_files) 101 | end 102 | 103 | # @private 104 | # @sig (Im::Loader, String) -> String 105 | def register_autoload(loader, abspath) 106 | paths[abspath] = autoloads[abspath] = loader 107 | end 108 | 109 | # @private 110 | # @sig (String) -> void 111 | def unregister_autoload(abspath) 112 | autoloads.delete(abspath) 113 | end 114 | 115 | # @private 116 | # @sig (Im::Loader, String) -> String 117 | def register_path(loader, abspath) 118 | paths[abspath] = loader 119 | end 120 | 121 | # @private 122 | # @sig (String) -> void 123 | def unregister_path(abspath) 124 | paths.delete(abspath) 125 | end 126 | 127 | # @private 128 | # @sig (String, String, Im::Loader) -> void 129 | def register_inception(cpath, abspath, loader) 130 | inceptions[cpath] = [abspath, loader] 131 | end 132 | 133 | # @private 134 | # @sig (String) -> String? 135 | def inception?(cpath) 136 | if pair = inceptions[cpath] 137 | pair.first 138 | end 139 | end 140 | 141 | # @private 142 | # @sig (Integer, String, Im::Loader) -> void 143 | def register_autoloaded_module(hash, module_name, loader) 144 | autoloaded_modules[hash] = [module_name, loader, []] 145 | end 146 | 147 | # @private 148 | # @sig (String) -> Im::Loader? 149 | def loader_for(path) 150 | paths[path] 151 | end 152 | 153 | # @private 154 | # @sig (Im::Loader) -> void 155 | def on_unload(loader) 156 | autoloads.delete_if { |_path, object| object == loader } 157 | inceptions.delete_if { |_cpath, (_path, object)| object == loader } 158 | end 159 | end 160 | 161 | @loaders = [] 162 | @gem_loaders_by_root_file = {} 163 | @autoloads = {} 164 | @paths = {} 165 | @inceptions = {} 166 | @autoloaded_modules = {} 167 | end 168 | end 169 | -------------------------------------------------------------------------------- /test/lib/im/test_on_load.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class TestOnLoad < LoaderTest 6 | test "on_load checks its argument type" do 7 | assert_raises(TypeError, "on_load only accepts strings") do 8 | loader.on_load(:X) {} 9 | end 10 | 11 | assert_raises(TypeError, "on_load only accepts strings") do 12 | loader.on_load(Object) {} 13 | end 14 | end 15 | 16 | test "on_load is called in the expected order, no namespace" do 17 | files = [ 18 | ["a.rb", "class A; end"], 19 | ["b.rb", "class B; end"] 20 | ] 21 | with_setup(files) do 22 | x = [] 23 | loader.on_load("A") { x << 1 } 24 | loader.on_load("B") { x << 2 } 25 | loader.on_load("A") { x << 3 } 26 | loader.on_load("B") { x << 4 } 27 | 28 | assert loader::A 29 | assert loader::B 30 | assert_equal [1, 3, 2, 4], x 31 | end 32 | end 33 | 34 | test "on_load is called in the expected order, implicit namespace" do 35 | files = [["x/a.rb", "class X::A; end"]] 36 | with_setup(files) do 37 | x = [] 38 | loader.on_load("X") { x << 1 } 39 | loader.on_load("X::A") { x << 2 } 40 | 41 | assert loader::X::A 42 | assert_equal [1, 2], x 43 | end 44 | end 45 | 46 | test "on_load is called in the expected order, explicit namespace" do 47 | files = [["x.rb", "module X; end"], ["x/a.rb", "class X::A; end"]] 48 | with_setup(files) do 49 | x = [] 50 | loader.on_load("X") { x << 1 } 51 | loader.on_load("X::A") { x << 2 } 52 | 53 | assert loader::X::A 54 | assert_equal [1, 2], x 55 | end 56 | end 57 | 58 | test "on_load gets the expected arguments passed" do 59 | with_setup([["x.rb", "X = 1"]]) do 60 | args = []; loader.on_load("X") { |*a| args = a } 61 | 62 | assert loader::X 63 | assert_equal 1, args[0] 64 | assert_abspath "x.rb", args[1] 65 | end 66 | end 67 | 68 | test "on_load for :ANY is called for files with the expected arguments" do 69 | with_setup([["x.rb", "X = 1"]]) do 70 | args = []; loader.on_load { |*a| args = a } 71 | 72 | assert loader::X 73 | assert_equal "X", args[0] 74 | assert_equal 1, args[1] 75 | assert_abspath "x.rb", args[2] 76 | end 77 | end 78 | 79 | test "on_load for :ANY is called for autovivified modules with the expected arguments" do 80 | with_setup([["x/a.rb", "X::A = 1"]]) do 81 | args = []; loader.on_load { |*a| args = a } 82 | 83 | assert loader::X 84 | assert_equal "X", args[0] 85 | assert_equal loader::X, args[1] 86 | assert_abspath "x", args[2] 87 | end 88 | end 89 | 90 | test "on_load for :ANY is called for namespaced constants with the expected arguments" do 91 | with_setup([["x/a.rb", "X::A = 1"]]) do 92 | args = []; loader.on_load { |*a| args = a } 93 | 94 | assert loader::X::A 95 | assert_equal "X::A", args[0] 96 | assert_equal loader::X::A, args[1] 97 | assert_abspath "x/a.rb", args[2] 98 | end 99 | end 100 | 101 | test "multiple on_load for :ANY are called in order" do 102 | with_setup([["x.rb", "X = 1"]]) do 103 | x = [] 104 | loader.on_load { x << 1 } 105 | loader.on_load { x << 2 } 106 | 107 | assert loader::X 108 | assert_equal [1, 2], x 109 | end 110 | end 111 | 112 | test "if there are specific and :ANY on_loads, the specific one runs first" do 113 | with_setup([["x.rb", "X = 1"]]) do 114 | x = [] 115 | loader.on_load { x << 2 } 116 | loader.on_load("X") { x << 1 } 117 | 118 | assert loader::X 119 | assert_equal [1, 2], x 120 | end 121 | end 122 | 123 | test "on_load survives reloads" do 124 | with_setup([["a.rb", "class A; end"]]) do 125 | x = 0; loader.on_load("A") { x += 1 } 126 | 127 | assert loader::A 128 | assert_equal 1, x 129 | 130 | loader.reload 131 | 132 | assert loader::A 133 | assert_equal 2, x 134 | end 135 | end 136 | 137 | test "on_load for namespaces gets called with child constants available (implicit)" do 138 | with_setup([["x/a.rb", "X::A = 1"]]) do 139 | ok = false 140 | loader.on_load("X") { ok = loader::X.const_defined?(:A) } 141 | 142 | assert loader::X 143 | assert ok 144 | end 145 | end 146 | 147 | test "on_load for namespaces gets called with child constants available (explicit)" do 148 | with_setup([["x.rb", "module X; end"], ["x/a.rb", "X::A = 1"]]) do 149 | ok = false 150 | loader.on_load("X") { ok = loader::X.const_defined?(:A) } 151 | 152 | assert loader::X 153 | assert ok 154 | end 155 | end 156 | 157 | test "on_load :ANY for namespaces gets called with child constants available (implicit)" do 158 | with_setup([["x/a.rb", "X::A = 1"]]) do 159 | ok = false 160 | loader.on_load { |cpath| ok = loader::X.const_defined?(:A) if cpath == "X" } 161 | 162 | assert loader::X 163 | assert ok 164 | end 165 | end 166 | 167 | test "on_load :ANY for namespaces gets called with child constants available (explicit)" do 168 | with_setup([["x.rb", "module X; end"], ["x/a.rb", "X::A = 1"]]) do 169 | ok = false 170 | loader.on_load { |cpath| ok = loader::X.const_defined?(:A) if cpath == "X" } 171 | 172 | assert loader::X 173 | assert ok 174 | end 175 | end 176 | 177 | test "if reloading is disabled, we deplete the hash (performance test)" do 178 | on_teardown do 179 | delete_loaded_feature "a.rb" 180 | end 181 | 182 | with_files([["a.rb", "class A; end"]]) do 183 | loader = new_loader(dirs: ".", enable_reloading: false, setup: false) 184 | x = 0; loader.on_load("A") { x = 1 } 185 | loader.setup 186 | 187 | assert !loader.send(:on_load_callbacks).empty? 188 | assert loader::A 189 | assert_equal 1, x 190 | assert loader.send(:on_load_callbacks).empty? 191 | end 192 | end 193 | end 194 | -------------------------------------------------------------------------------- /test/lib/im/test_eager_load_namespace.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class TestEagerLoadNamespaceWithObjectRootNamespace < LoaderTest 4 | test "eager loads everything" do 5 | files = [["x.rb", "X = 1"], ["m/x.rb", "M::X = 1"]] 6 | with_setup(files) do 7 | loader.eager_load_namespace(loader) 8 | 9 | assert required?(files) 10 | end 11 | end 12 | 13 | test "shortcircuits if eager loaded" do 14 | with_setup do 15 | loader.eager_load 16 | 17 | # Dirty way to prove we shortcircuit. 18 | def loader.actual_eager_load_dir(*) 19 | raise 20 | end 21 | 22 | begin 23 | loader.eager_load_namespace(loader) 24 | rescue 25 | flunk 26 | else 27 | pass 28 | end 29 | end 30 | end 31 | 32 | test "does not assume the namespace has a name" do 33 | files = [["x.rb", "X = 1"]] 34 | with_setup(files) do 35 | loader.eager_load_namespace(Module.new) 36 | 37 | assert !required?(files[0]) 38 | end 39 | end 40 | 41 | test "eager loads everything (multiple root directories)" do 42 | files = [ 43 | ["rd1/x.rb", "X = 1"], 44 | ["rd1/m/x.rb", "M::X = 1"], 45 | ["rd2/y.rb", "Y = 1"], 46 | ["rd2/m/y.rb", "M::Y = 1"] 47 | ] 48 | with_setup(files) do 49 | loader.eager_load_namespace(loader) 50 | 51 | assert required?(files) 52 | end 53 | end 54 | 55 | test "supports collapsed directories" do 56 | files = [ 57 | ["rd1/collapsed/m/x.rb", "M::X = 1"], 58 | ["rd2/y.rb", "Y = 1"], 59 | ["rd2/m/y.rb", "M::Y = 1"] 60 | ] 61 | with_setup(files) do 62 | loader.eager_load_namespace(loader::M) 63 | assert required?(files[0]) 64 | assert !required?(files[1]) 65 | assert required?(files[2]) 66 | end 67 | end 68 | 69 | test "eager loads everything (nested root directories)" do 70 | files = [ 71 | ["x.rb", "X = 1"], 72 | ["m/x.rb", "M::X = 1"], 73 | ["nested/y.rb", "Y = 1"], 74 | ["nested/m/y.rb", "M::Y = 1"] 75 | ] 76 | with_setup(files, dirs: %w(. nested)) do 77 | loader.eager_load_namespace(loader) 78 | 79 | assert required?(files) 80 | end 81 | end 82 | 83 | test "eager loads a managed namespace" do 84 | files = [["x.rb", "X = 1"], ["m/x.rb", "M::X = 1"]] 85 | with_setup(files) do 86 | loader.eager_load_namespace(loader::M) 87 | 88 | assert !required?(files[0]) 89 | assert required?(files[1]) 90 | end 91 | end 92 | 93 | test "eager loading a non-managed namespace does not raise" do 94 | files = [["x.rb", "X = 1"]] 95 | with_setup(files) do 96 | loader.eager_load_namespace(self.class) 97 | 98 | assert !required?(files[0]) 99 | end 100 | end 101 | 102 | test "does not eager load ignored files" do 103 | files = [["x.rb", "X = 1"], ["ignored.rb", "IGNORED"]] 104 | with_setup(files) do 105 | loader.eager_load_namespace(loader) 106 | 107 | assert required?(files[0]) 108 | assert !required?(files[1]) 109 | end 110 | end 111 | 112 | test "does not eager load shadowed files" do 113 | files = [["rd1/x.rb", "X = 1"], ["rd2/x.rb", "X = 1"]] 114 | with_setup(files) do 115 | loader.eager_load_namespace(loader) 116 | 117 | assert required?(files[0]) 118 | assert !required?(files[1]) 119 | end 120 | end 121 | 122 | test "skips root directories which are excluded from eager loading (Object)" do 123 | files = [["rd1/a.rb", "A = 1"], ["rd2/b.rb", "B = 1"]] 124 | with_setup(files) do 125 | loader.do_not_eager_load("rd1") 126 | loader.eager_load_namespace(loader) 127 | 128 | assert !required?(files[0]) 129 | assert required?(files[1]) 130 | end 131 | end 132 | 133 | test "skips directories which are excluded from eager loading (namespace, ancestor)" do 134 | files = [["rd1/m/a.rb", "M::A = 1"], ["rd2/m/b.rb", "M::B = 1"]] 135 | with_setup(files) do 136 | loader.do_not_eager_load("rd1/m") 137 | loader.eager_load_namespace(loader) 138 | 139 | assert !required?(files[0]) 140 | assert required?(files[1]) 141 | end 142 | end 143 | 144 | test "skips directories which are excluded from eager loading (namespace, self)" do 145 | files = [["rd1/m/a.rb", "M::A = 1"], ["rd2/m/b.rb", "M::B = 1"]] 146 | with_setup(files) do 147 | loader.do_not_eager_load("rd1/m") 148 | loader.eager_load_namespace(loader::M) 149 | 150 | assert !required?(files[0]) 151 | assert required?(files[1]) 152 | end 153 | end 154 | 155 | test "skips directories which are excluded from eager loading (namespace, descendant)" do 156 | files = [["rd1/m/n/a.rb", "M::N::A = 1"], ["rd2/m/n/b.rb", "M::N::B = 1"]] 157 | with_setup(files) do 158 | loader.do_not_eager_load("rd1/m") 159 | loader.eager_load_namespace(loader::M::N) 160 | 161 | assert !required?(files[0]) 162 | assert required?(files[1]) 163 | end 164 | end 165 | 166 | test "does not eager load namespaces from other loaders" do 167 | files = [["a/m/x.rb", "M::X = 1"], ["b/m/y.rb", "M::Y = 1"]] 168 | with_files(files) do 169 | loader.push_dir("a") 170 | loader.setup 171 | 172 | loader_b = new_loader(dirs: "b") 173 | loader_b.eager_load_namespace(loader_b::M) 174 | 175 | assert !required?(files[0]) 176 | assert required?(files[1]) 177 | end 178 | end 179 | 180 | test "raises if the argument is not a class or module object" do 181 | with_setup do 182 | e = assert_raises(Im::Error) do 183 | loader.eager_load_namespace(self.class.name) 184 | end 185 | assert_equal %Q("#{self.class.name}" is not a class or module object), e.message 186 | end 187 | end 188 | 189 | test "raises if the argument is not a class or module object, even if eager loaded" do 190 | with_setup do 191 | loader.eager_load 192 | e = assert_raises(Im::Error) do 193 | loader.eager_load_namespace(self.class.name) 194 | end 195 | assert_equal %Q("#{self.class.name}" is not a class or module object), e.message 196 | end 197 | end 198 | end 199 | -------------------------------------------------------------------------------- /test/lib/im/test_logging.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class TestLogging < LoaderTest 6 | def setup 7 | super 8 | loader.logger = method(:print) 9 | end 10 | 11 | def teardown 12 | Im::Loader.default_logger = nil 13 | loader.logger = nil 14 | super 15 | end 16 | 17 | def tagged_message(message) 18 | "Im@#{loader.tag}: #{message}" 19 | end 20 | 21 | def assert_logged(expected) 22 | case expected 23 | when String 24 | assert_output(tagged_message(expected)) { yield } 25 | when Regexp 26 | assert_output(/#{tagged_message(expected)}/) { yield } 27 | end 28 | end 29 | 30 | test "log! just prints to $stdout" do 31 | loader.logger = nil # make sure we are setting something 32 | loader.log! 33 | message = "test log!" 34 | assert_logged(/#{message}\n/) { loader.send(:log, message) } 35 | end 36 | 37 | test "accepts objects that respond to :call" do 38 | logger = Object.new 39 | def logger.call(message) 40 | print message 41 | end 42 | 43 | loader.logger = logger 44 | 45 | message = "test message :call" 46 | assert_logged(message) { loader.send(:log, message) } 47 | end 48 | 49 | test "accepts objects that respond to :debug" do 50 | logger = Object.new 51 | def logger.debug(message) 52 | print message 53 | end 54 | 55 | loader.logger = logger 56 | 57 | message = "test message :debug" 58 | assert_logged(message) { loader.send(:log, message) } 59 | end 60 | 61 | test "new loaders get assigned the default global logger" do 62 | assert_nil Im::Loader.new.logger 63 | 64 | Im::Loader.default_logger = Object.new 65 | assert_same Im::Loader.default_logger, Im::Loader.new.logger 66 | end 67 | 68 | test "logs loaded files" do 69 | files = [["x.rb", "X = true"]] 70 | with_files(files) do 71 | with_load_path(".") do 72 | assert_logged(/constant X loaded from file #{File.expand_path("x.rb")}/) do 73 | loader.push_dir(".") 74 | loader.setup 75 | 76 | assert loader::X 77 | end 78 | end 79 | end 80 | end 81 | 82 | test "logs required managed files" do 83 | files = [["x.rb", "X = true"]] 84 | with_files(files) do 85 | with_load_path(".") do 86 | assert_logged(/constant X loaded from file #{File.expand_path("x.rb")}/) do 87 | loader.push_dir(".") 88 | loader.setup 89 | 90 | assert require "x" 91 | end 92 | end 93 | end 94 | end 95 | 96 | test "logs autovivified modules" do 97 | files = [["admin/user.rb", "class Admin::User; end"]] 98 | with_files(files) do 99 | with_load_path(".") do 100 | assert_logged(/module Admin autovivified from directory #{File.expand_path("admin")}/) do 101 | loader.push_dir(".") 102 | loader.setup 103 | 104 | assert loader::Admin 105 | end 106 | end 107 | end 108 | end 109 | 110 | test "logs implicit to explicit promotions" do 111 | # We use two root directories to make sure the loader visits the implicit 112 | # a/m first, and the explicit b/m.rb after it. 113 | files = [ 114 | ["a/m/x.rb", "M::X = true"], 115 | ["b/m.rb", "module M; end"] 116 | ] 117 | with_files(files) do 118 | loader.push_dir("a") 119 | loader.push_dir("b") 120 | assert_logged(/earlier autoload for #{loader}::M discarded, it is actually an explicit namespace defined in #{File.expand_path("b/m.rb")}/) do 121 | loader.setup 122 | end 123 | end 124 | end 125 | 126 | test "logs autoload configured for files" do 127 | files = [["x.rb", "X = true"]] 128 | with_files(files) do 129 | assert_logged("autoload set for #{loader}::X, to be loaded from #{File.expand_path("x.rb")}") do 130 | loader.push_dir(".") 131 | loader.setup 132 | end 133 | end 134 | end 135 | 136 | test "logs failed autoloads, provided the require call succeeded" do 137 | files = [["x.rb", ""]] 138 | with_files(files) do 139 | assert_logged(/expected file #{File.expand_path("x.rb")} to define constant #{loader.to_s}::X, but didn't/) do 140 | loader.push_dir(".") 141 | loader.setup 142 | assert_raises(Im::NameError) { loader::X } 143 | end 144 | end 145 | end 146 | 147 | test "logs autoload configured for directories" do 148 | files = [["admin/user.rb", "class Admin::User; end"]] 149 | with_files(files) do 150 | assert_logged("autoload set for #{loader}::Admin, to be autovivified from #{File.expand_path("admin")}") do 151 | loader.push_dir(".") 152 | loader.setup 153 | end 154 | end 155 | end 156 | 157 | test "logs unloads for autoloads" do 158 | files = [["x.rb", "X = true"]] 159 | with_files(files) do 160 | assert_logged(/autoload for #{loader}::X removed/) do 161 | loader.push_dir(".") 162 | loader.setup 163 | loader.reload 164 | end 165 | end 166 | end 167 | 168 | test "logs unloads for loaded objects" do 169 | files = [["x.rb", "X = true"]] 170 | with_files(files) do 171 | assert_logged(/#{loader}::X unloaded/) do 172 | loader.push_dir(".") 173 | loader.setup 174 | assert loader::X 175 | loader.reload 176 | end 177 | end 178 | end 179 | 180 | test "logs when eager loading starts" do 181 | with_setup do 182 | assert_logged(/eager load start/) do 183 | loader.eager_load 184 | end 185 | end 186 | end 187 | 188 | test "logs when eager loading ends" do 189 | with_setup do 190 | assert_logged(/eager load end/) do 191 | loader.eager_load 192 | end 193 | end 194 | end 195 | 196 | test "eager loading skips files that would map to already loaded constants" do 197 | loader::X = 1 198 | files = [["x.rb", "X = 1"]] 199 | with_files(files) do 200 | loader.push_dir(".") 201 | assert_logged(%r(file .*?/x\.rb is ignored because #{loader}::X is already defined)) do 202 | loader.setup 203 | end 204 | end 205 | end 206 | end 207 | -------------------------------------------------------------------------------- /test/lib/im/test_reloading.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "fileutils" 5 | 6 | class TestReloading < LoaderTest 7 | def silence_exceptions_in_threads 8 | original_report_on_exception = Thread.report_on_exception 9 | Thread.report_on_exception = false 10 | yield 11 | ensure 12 | Thread.report_on_exception = original_report_on_exception 13 | end 14 | 15 | test "enabling reloading after setup raises" do 16 | e = assert_raises(Im::Error) do 17 | loader = Im::Loader.new 18 | loader.setup 19 | loader.enable_reloading 20 | end 21 | assert_equal "cannot enable reloading after setup", e.message 22 | end 23 | 24 | test "enabling reloading is idempotent, even after setup" do 25 | assert loader.reloading_enabled? # precondition 26 | loader.setup 27 | loader.enable_reloading # should not raise 28 | assert loader.reloading_enabled? 29 | end 30 | 31 | test "reloading works if the flag is set (Object)" do 32 | files = [ 33 | ["x.rb", "X = 1"], # top-level 34 | ["y.rb", "module Y; end"], # explicit namespace 35 | ["y/a.rb", "Y::A = 1"], 36 | ["z/a.rb", "Z::A = 1"] # implicit namespace 37 | ] 38 | with_setup(files) do 39 | assert_equal 1, loader::X 40 | assert_equal 1, loader::Y::A 41 | assert_equal 1, loader::Z::A 42 | 43 | y_hash = loader::Y.hash 44 | z_hash = loader::Z.hash 45 | 46 | File.write("x.rb", "X = 2") 47 | File.write("y/a.rb", "Y::A = 2") 48 | File.write("z/a.rb", "Z::A = 2") 49 | 50 | loader.reload 51 | 52 | assert_equal 2, loader::X 53 | assert_equal 2, loader::Y::A 54 | assert_equal 2, loader::Z::A 55 | 56 | assert loader::Y.hash != y_hash 57 | assert loader::Z.hash != z_hash 58 | 59 | assert_equal 2, loader::X 60 | end 61 | end 62 | 63 | test "reloading raises if the flag is not set" do 64 | e = assert_raises(Im::ReloadingDisabledError) do 65 | loader = Im::Loader.new 66 | loader.setup 67 | loader.reload 68 | end 69 | assert_equal "can't reload, please call loader.enable_reloading before setup", e.message 70 | end 71 | 72 | test "if reloading is disabled, autoloading metadata shrinks while autoloading (performance test)" do 73 | on_teardown do 74 | delete_loaded_feature "x.rb" 75 | delete_loaded_feature "y.rb" 76 | delete_loaded_feature "y/a.rb" 77 | delete_loaded_feature "z/a.rb" 78 | end 79 | 80 | files = [ 81 | ["x.rb", "X = 1"], 82 | ["y.rb", "module Y; end"], 83 | ["y/a.rb", "Y::A = 1"], 84 | ["z/a.rb", "Z::A = 1"] 85 | ] 86 | with_files(files) do 87 | loader = new_loader(dirs: ".", enable_reloading: false) 88 | 89 | assert !loader.__autoloads.empty? 90 | 91 | assert_equal 1, loader::X 92 | assert_equal 1, loader::Y::A 93 | assert_equal 1, loader::Z::A 94 | 95 | assert loader.__autoloads.empty? 96 | assert loader.__to_unload.empty? 97 | end 98 | end 99 | 100 | test "if reloading is disabled, autoloading metadata shrinks while eager loading (performance test)" do 101 | on_teardown do 102 | delete_loaded_feature "x.rb" 103 | delete_loaded_feature "y.rb" 104 | delete_loaded_feature "y/a.rb" 105 | delete_loaded_feature "z/a.rb" 106 | end 107 | 108 | files = [ 109 | ["x.rb", "X = 1"], 110 | ["y.rb", "module Y; end"], 111 | ["y/a.rb", "Y::A = 1"], 112 | ["z/a.rb", "Z::A = 1"] 113 | ] 114 | with_files(files) do 115 | loader = new_loader(dirs: ".", enable_reloading: false) 116 | 117 | assert !loader.__autoloads.empty? 118 | assert !Im::Registry.autoloads.empty? 119 | 120 | loader.eager_load 121 | 122 | assert loader.__autoloads.empty? 123 | assert Im::Registry.autoloads.empty? 124 | assert loader.__to_unload.empty? 125 | end 126 | end 127 | 128 | test "reloading supports deleted root directories" do 129 | files = [["rd1/x.rb", "X = 1"], ["rd2/y.rb", "Y = 1"]] 130 | with_setup(files) do 131 | assert loader::X 132 | assert loader::Y 133 | 134 | FileUtils.rm_rf("rd2") 135 | loader.reload 136 | 137 | assert loader::X 138 | end 139 | end 140 | 141 | test "you can eager load again after reloading" do 142 | $test_eager_load_after_reload = 0 143 | files = [["x.rb", "$test_eager_load_after_reload += 1; X = 1"]] 144 | with_setup(files) do 145 | loader.eager_load 146 | assert_equal 1, $test_eager_load_after_reload 147 | 148 | loader.reload 149 | 150 | loader.eager_load 151 | assert_equal 2, $test_eager_load_after_reload 152 | end 153 | end 154 | 155 | test "reload recovers from name errors (w/o on_unload callbacks)" do 156 | files = [["x.rb", "Y = :typo"]] 157 | with_setup(files) do 158 | assert_raises(Im::NameError) { loader::X } 159 | 160 | assert !loader.constants.include?(:X) 161 | assert !loader.const_defined?(:X, false) 162 | assert !loader.autoload?(:X) 163 | 164 | loader.reload 165 | File.write("x.rb", "X = true") 166 | 167 | assert loader.constants.include?(:X) 168 | assert loader.const_defined?(:X, false) 169 | assert loader.autoload?(:X) 170 | 171 | assert loader::X 172 | end 173 | end 174 | 175 | test "reload recovers from name errors (w/ on_unload callbacks)" do 176 | files = [["x.rb", "Y = :typo"]] 177 | with_setup(files) do 178 | loader.on_unload {} 179 | assert_raises(Im::NameError) { loader::X } 180 | 181 | assert !loader.constants.include?(:X) 182 | assert !loader.const_defined?(:X, false) 183 | assert !loader.autoload?(:X) 184 | 185 | loader.reload 186 | File.write("x.rb", "X = true") 187 | 188 | assert loader.constants.include?(:X) 189 | assert loader.const_defined?(:X, false) 190 | assert loader.autoload?(:X) 191 | 192 | assert loader::X 193 | end 194 | end 195 | 196 | test "raises if called before setup" do 197 | assert_raises(Im::SetupRequired) do 198 | loader.reload 199 | end 200 | end 201 | end 202 | -------------------------------------------------------------------------------- /test/lib/im/test_explicit_namespace.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class TestExplicitNamespace < LoaderTest 6 | test "explicit namespaces are loaded correctly (directory first, Object)" do 7 | files = [ 8 | ["hotel.rb", "class Hotel; X = 1; end"], 9 | ["hotel/pricing.rb", "class Hotel::Pricing; end"] 10 | ] 11 | with_setup(files) do 12 | assert_kind_of Class, loader::Hotel 13 | assert loader::Hotel::X 14 | assert loader::Hotel::Pricing 15 | end 16 | end 17 | 18 | test "explicit namespaces are loaded correctly (file first, Object)" do 19 | files = [ 20 | ["rd1/hotel.rb", "class Hotel; X = 1; end"], 21 | ["rd2/hotel/pricing.rb", "class Hotel::Pricing; end"] 22 | ] 23 | with_setup(files) do 24 | assert_kind_of Class, loader::Hotel 25 | assert loader::Hotel::X 26 | assert loader::Hotel::Pricing 27 | end 28 | end 29 | 30 | test "explicit namespaces are loaded correctly even if #name is overridden" do 31 | files = [ 32 | ["hotel.rb", <<~RUBY], 33 | class Hotel 34 | def self.name 35 | "X" 36 | end 37 | end 38 | RUBY 39 | ["hotel/pricing.rb", "class Hotel::Pricing; end"] 40 | ] 41 | with_setup(files) do 42 | assert loader::Hotel::Pricing 43 | end 44 | end 45 | 46 | test "autoloads are set correctly, even if there are autoloads for the same cname in the superclass" do 47 | files = [ 48 | ["a.rb", "class A; end"], 49 | ["a/x.rb", "A::X = :A"], 50 | ["b.rb", "class B < A; end"], 51 | ["b/x.rb", "B::X = :B"] 52 | ] 53 | with_setup(files) do 54 | assert_kind_of Class, loader::A 55 | assert_kind_of Class, loader::B 56 | assert_equal :B, loader::B::X 57 | end 58 | end 59 | 60 | test "autoloads are set correctly, even if there are autoloads for the same cname in a module prepended to the superclass" do 61 | files = [ 62 | ["m/x.rb", "M::X = :M"], 63 | ["a.rb", "class A; prepend M; end"], 64 | ["b.rb", "class B < A; end"], 65 | ["b/x.rb", "B::X = :B"] 66 | ] 67 | with_setup(files) do 68 | assert_kind_of Class, loader::A 69 | assert_kind_of Class, loader::B 70 | assert_equal :B, loader::B::X 71 | end 72 | end 73 | 74 | test "autoloads are set correctly, even if there are autoloads for the same cname in other ancestors" do 75 | files = [ 76 | ["m/x.rb", "M::X = :M"], 77 | ["a.rb", "class A; include M; end"], 78 | ["b.rb", "class B < A; end"], 79 | ["b/x.rb", "B::X = :B"] 80 | ] 81 | with_setup(files) do 82 | assert_kind_of Class, loader::A 83 | assert_kind_of Class, loader::B 84 | assert_equal :B, loader::B::X 85 | end 86 | end 87 | 88 | test "namespace promotion updates the registry" do 89 | # We use two root directories to make sure the loader visits the implicit 90 | # rd1/m first, and the explicit rd2/m.rb after it. 91 | files = [ 92 | ["rd1/m/x.rb", "M::X = true"], 93 | ["rd2/m.rb", "module M; end"] 94 | ] 95 | with_setup(files) do 96 | assert_nil Im::Registry.loader_for(File.expand_path("rd1/m")) 97 | assert_same loader, Im::Registry.loader_for(File.expand_path("rd2/m.rb")) 98 | end 99 | end 100 | 101 | # As of this writing, a tracer on the :class event does not seem to have any 102 | # performance penalty in an ordinary code base. But I prefer to precisely 103 | # control that we use a tracer only if needed in case this issue 104 | # 105 | # https://bugs.ruby-lang.org/issues/14104 106 | # 107 | # goes forward. 108 | def tracer 109 | Im::ExplicitNamespace.send(:tracer) 110 | end 111 | 112 | test "the tracer starts disabled" do 113 | assert !tracer.enabled? 114 | end 115 | 116 | test "simple autoloading does not enable the tracer" do 117 | files = [["x.rb", "X = true"]] 118 | with_setup(files) do 119 | assert !tracer.enabled? 120 | assert loader::X 121 | assert !tracer.enabled? 122 | end 123 | end 124 | 125 | test "autovivification does not enable the tracer, one directory" do 126 | files = [["foo/bar.rb", "module Foo::Bar; end"]] 127 | with_setup(files) do 128 | assert !tracer.enabled? 129 | assert loader::Foo::Bar 130 | assert !tracer.enabled? 131 | end 132 | end 133 | 134 | test "autovivification does not enable the tracer, two directories" do 135 | files = [ 136 | ["rd1/foo/bar.rb", "module Foo::Bar; end"], 137 | ["rd2/foo/baz.rb", "module Foo::Baz; end"], 138 | ] 139 | with_setup(files) do 140 | assert !tracer.enabled? 141 | assert loader::Foo::Bar 142 | assert !tracer.enabled? 143 | end 144 | end 145 | 146 | test "explicit namespaces enable the tracer until loaded" do 147 | files = [ 148 | ["hotel.rb", "class Hotel; end"], 149 | ["hotel/pricing.rb", "class Hotel::Pricing; end"] 150 | ] 151 | with_setup(files) do 152 | assert tracer.enabled? 153 | assert loader::Hotel 154 | assert !tracer.enabled? 155 | assert loader::Hotel::Pricing 156 | assert !tracer.enabled? 157 | end 158 | end 159 | 160 | # This is a regression test. 161 | test "the tracer handles singleton classes" do 162 | files = [ 163 | ["hotel.rb", <<-EOS], 164 | class Hotel 165 | class << self 166 | def x 167 | 1 168 | end 169 | end 170 | end 171 | EOS 172 | ["hotel/pricing.rb", "class Hotel::Pricing; end"], 173 | ["car.rb", "class Car; end"], 174 | ["car/pricing.rb", "class Car::Pricing; end"], 175 | ] 176 | with_setup(files) do 177 | assert tracer.enabled? 178 | assert_equal 1, loader::Hotel.x 179 | assert tracer.enabled? 180 | end 181 | end 182 | 183 | test "non-hashable explicit namespaces are supported" do 184 | files = [ 185 | ["m.rb", <<~EOS], 186 | module M 187 | # This method is overridden with a different arity. Therefore, M is 188 | # not hashable. See https://github.com/fxn/zeitwerk/issues/188. 189 | def self.hash(_) 190 | end 191 | end 192 | EOS 193 | ["m/x.rb", "M::X = true"] 194 | ] 195 | with_setup(files) do 196 | assert loader::M::X 197 | end 198 | end 199 | end 200 | -------------------------------------------------------------------------------- /test/lib/im/test_require_interaction.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "pathname" 5 | 6 | class TestRequireInteraction < LoaderTest 7 | def assert_required(str) 8 | assert_equal true, require(str) 9 | end 10 | 11 | def assert_not_required(str) 12 | assert_equal false, require(str) 13 | end 14 | 15 | test "our decorated require returns true or false as expected" do 16 | on_teardown do 17 | remove_const :User, from: Object 18 | delete_loaded_feature "user.rb" 19 | end 20 | 21 | files = [["user.rb", "class User; end"]] 22 | with_files(files) do 23 | with_load_path(".") do 24 | assert_required "user" 25 | assert_not_required "user" 26 | end 27 | end 28 | end 29 | 30 | test "our decorated require returns true or false as expected (Pathname)" do 31 | on_teardown do 32 | remove_const :User, from: Object 33 | delete_loaded_feature "user.rb" 34 | end 35 | 36 | files = [["user.rb", "class User; end"]] 37 | pathname_for_user = Pathname.new("user") 38 | with_files(files) do 39 | with_load_path(".") do 40 | assert_required pathname_for_user 41 | assert_not_required pathname_for_user 42 | end 43 | end 44 | end 45 | 46 | test "autoloading makes require idempotent even with a relative path" do 47 | files = [["user.rb", "class User; end"]] 48 | with_setup(files, load_path: ".") do 49 | assert loader::User 50 | assert_not_required "user" 51 | end 52 | end 53 | 54 | test "a required top-level file is still detected as autoloadable" do 55 | files = [["user.rb", "class User; end"]] 56 | with_setup(files, load_path: ".") do 57 | assert_required "user" 58 | loader.unload 59 | assert !loader.const_defined?(:User, false) 60 | 61 | loader.setup 62 | assert loader::User 63 | end 64 | end 65 | 66 | test "a required top-level file is still detected as autoloadable (Pathname)" do 67 | files = [["user.rb", "class User; end"]] 68 | with_setup(files, load_path: ".") do 69 | assert_required Pathname.new("user") 70 | assert loader::User 71 | loader.unload 72 | assert !loader.const_defined?(:User, false) 73 | 74 | loader.setup 75 | assert loader::User 76 | end 77 | end 78 | 79 | test "require autovivifies as needed" do 80 | files = [ 81 | ["rd1/admin/user.rb", "class Admin::User; end"], 82 | ["rd2/admin/users_controller.rb", "class Admin::UsersController; end"] 83 | ] 84 | with_setup(files, load_path: %w(rd1 rd2)) do 85 | assert_required "admin/user" 86 | 87 | assert loader::Admin::User 88 | assert loader::Admin::UsersController 89 | 90 | loader.unload 91 | assert !loader.const_defined?(:Admin) 92 | end 93 | end 94 | 95 | test "files deep down the current visited level are recognized as managed (implicit)" do 96 | files = [["foo/bar/baz/zoo/woo.rb", "Foo::Bar::Baz::Zoo::Woo = 1"]] 97 | with_setup(files, load_path: ".") do 98 | assert_required "foo/bar/baz/zoo/woo" 99 | assert loader.unloadable_cpath?("Foo::Bar::Baz::Zoo::Woo") 100 | end 101 | end 102 | 103 | test "files deep down the current visited level are recognized as managed (explicit)" do 104 | files = [ 105 | ["foo/bar/baz/zoo.rb", "module Foo::Bar::Baz::Zoo; include Wadus; end"], 106 | ["foo/bar/baz/zoo/wadus.rb", "module Foo::Bar::Baz::Zoo::Wadus; end"], 107 | ["foo/bar/baz/zoo/woo.rb", "Foo::Bar::Baz::Zoo::Woo = 1"] 108 | ] 109 | with_setup(files, load_path: ".") do 110 | assert_required "foo/bar/baz/zoo/woo" 111 | assert loader.unloadable_cpath?("Foo::Bar::Baz::Zoo::Wadus") 112 | assert loader.unloadable_cpath?("Foo::Bar::Baz::Zoo::Woo") 113 | end 114 | end 115 | 116 | test "require works well with explicit namespaces" do 117 | files = [ 118 | ["hotel.rb", "class Hotel; X = true; end"], 119 | ["hotel/pricing.rb", "class Hotel::Pricing; end"] 120 | ] 121 | with_setup(files, load_path: ".") do 122 | assert_required "hotel/pricing" 123 | assert loader::Hotel::Pricing 124 | assert loader::Hotel::X 125 | end 126 | end 127 | 128 | test "you can autoload yourself in a required file" do 129 | files = [ 130 | ["my_gem.rb", <<-EOS], 131 | loader = Im::Loader.new 132 | loader.push_dir(__dir__) 133 | loader.enable_reloading 134 | loader.setup 135 | 136 | module loader::MyGem; end 137 | EOS 138 | ["my_gem/foo.rb", "class MyGem::Foo; end"] 139 | ] 140 | with_files(files) do 141 | with_load_path(Dir.pwd) do 142 | assert_required "my_gem" 143 | end 144 | end 145 | end 146 | 147 | test "does not autovivify while loading an explicit namespace, constant is not yet defined - file first" do 148 | files = [ 149 | ["hotel.rb", <<-EOS], 150 | loader = Im::Loader.new 151 | loader.push_dir(__dir__) 152 | loader.enable_reloading 153 | loader.setup 154 | 155 | loader::Hotel.name 156 | 157 | class loader::Hotel 158 | end 159 | EOS 160 | ["hotel/pricing.rb", "class Hotel::Pricing; end"] 161 | ] 162 | with_files(files) do 163 | iter = ->(dir, &block) do 164 | if dir == Dir.pwd 165 | block.call("hotel.rb") 166 | block.call("hotel") 167 | end 168 | end 169 | Dir.stub :foreach, iter do 170 | e = assert_raises(NameError) do 171 | with_load_path(Dir.pwd) do 172 | assert_required "hotel" 173 | end 174 | end 175 | assert_match %r/Hotel/, e.message 176 | end 177 | end 178 | end 179 | 180 | test "does not autovivify while loading an explicit namespace, constant is not yet defined - file last" do 181 | files = [ 182 | ["hotel.rb", <<-EOS], 183 | loader = Im::Loader.new 184 | loader.push_dir(__dir__) 185 | loader.enable_reloading 186 | loader.setup 187 | 188 | loader::Hotel.name 189 | 190 | class loader::Hotel 191 | end 192 | EOS 193 | ["hotel/pricing.rb", "class Hotel::Pricing; end"] 194 | ] 195 | with_files(files) do 196 | iter = ->(dir, &block) do 197 | if dir == Dir.pwd 198 | block.call("hotel") 199 | block.call("hotel.rb") 200 | end 201 | end 202 | Dir.stub :foreach, iter do 203 | e = assert_raises(NameError) do 204 | with_load_path(Dir.pwd) do 205 | assert_required "hotel" 206 | end 207 | end 208 | assert_match %r/Hotel/, e.message 209 | end 210 | end 211 | end 212 | end 213 | -------------------------------------------------------------------------------- /lib/im/loader/eager_load.rb: -------------------------------------------------------------------------------- 1 | module Im::Loader::EagerLoad 2 | # Eager loads all files in the root directories, recursively. Files do not 3 | # need to be in `$LOAD_PATH`, absolute file names are used. Ignored and 4 | # shadowed files are not eager loaded. You can opt-out specifically in 5 | # specific files and directories with `do_not_eager_load`, and that can be 6 | # overridden passing `force: true`. 7 | # 8 | # @sig (true | false) -> void 9 | def eager_load(force: false) 10 | mutex.synchronize do 11 | break if @eager_loaded 12 | raise Im::SetupRequired unless @setup 13 | 14 | log("eager load start") if logger 15 | 16 | actual_roots.each do |root_dir| 17 | actual_eager_load_dir(root_dir, self, force: force) 18 | end 19 | 20 | autoloaded_dirs.each do |autoloaded_dir| 21 | Im::Registry.unregister_autoload(autoloaded_dir) 22 | end 23 | autoloaded_dirs.clear 24 | 25 | @eager_loaded = true 26 | 27 | log("eager load end") if logger 28 | end 29 | end 30 | 31 | # @sig (String | Pathname) -> void 32 | def eager_load_dir(path) 33 | raise Im::SetupRequired unless @setup 34 | 35 | abspath = File.expand_path(path) 36 | 37 | raise Im::Error.new("#{abspath} is not a directory") unless dir?(abspath) 38 | 39 | cnames = [] 40 | 41 | found_root = false 42 | walk_up(abspath) do |dir| 43 | return if ignored_path?(dir) 44 | return if eager_load_exclusions.member?(dir) 45 | 46 | break if found_root = root_dirs.include?(dir) 47 | 48 | unless collapse?(dir) 49 | basename = File.basename(dir) 50 | cnames << inflector.camelize(basename, dir).to_sym 51 | end 52 | end 53 | 54 | raise Im::Error.new("I do not manage #{abspath}") unless found_root 55 | 56 | return if @eager_loaded 57 | 58 | namespace = self 59 | cnames.reverse_each do |cname| 60 | # Can happen if there are no Ruby files. This is not an error condition, 61 | # the directory is actually managed. Could have Ruby files later. 62 | return unless cdef?(namespace, cname) 63 | namespace = cget(namespace, cname) 64 | end 65 | 66 | # A shortcircuiting test depends on the invocation of this method. Please 67 | # keep them in sync if refactored. 68 | actual_eager_load_dir(abspath, namespace) 69 | end 70 | 71 | # @sig (Module) -> void 72 | def eager_load_namespace(mod) 73 | raise Im::SetupRequired unless @setup 74 | 75 | unless mod.is_a?(Module) 76 | raise Im::Error, "#{mod.inspect} is not a class or module object" 77 | end 78 | 79 | return if @eager_loaded 80 | 81 | mod_name = Im.cpath(mod) 82 | 83 | actual_roots.each do |root_dir| 84 | if mod.equal?(self) 85 | # A shortcircuiting test depends on the invocation of this method. 86 | # Please keep them in sync if refactored. 87 | actual_eager_load_dir(root_dir, self) 88 | else 89 | eager_load_child_namespace(mod, mod_name, root_dir) 90 | end 91 | end 92 | end 93 | 94 | # Loads the given Ruby file. 95 | # 96 | # Raises if the argument is ignored, shadowed, or not managed by the receiver. 97 | # 98 | # The method is implemented as `constantize` for files, in a sense, to be able 99 | # to descend orderly and make sure the file is loadable. 100 | # 101 | # @sig (String | Pathname) -> void 102 | def load_file(path) 103 | abspath = File.expand_path(path) 104 | 105 | raise Im::Error.new("#{abspath} does not exist") unless File.exist?(abspath) 106 | raise Im::Error.new("#{abspath} is not a Ruby file") if dir?(abspath) || !ruby?(abspath) 107 | raise Im::Error.new("#{abspath} is ignored") if ignored_path?(abspath) 108 | 109 | basename = File.basename(abspath, ".rb") 110 | base_cname = inflector.camelize(basename, abspath).to_sym 111 | 112 | root_included = false 113 | cnames = [] 114 | 115 | walk_up(File.dirname(abspath)) do |dir| 116 | raise Im::Error.new("#{abspath} is ignored") if ignored_path?(dir) 117 | 118 | break if root_included = root_dirs.include?(dir) 119 | 120 | unless collapse?(dir) 121 | basename = File.basename(dir) 122 | cnames << inflector.camelize(basename, dir).to_sym 123 | end 124 | end 125 | 126 | raise Im::Error.new("I do not manage #{abspath}") unless root_included 127 | 128 | namespace = self 129 | cnames.reverse_each do |cname| 130 | namespace = cget(namespace, cname) 131 | end 132 | 133 | raise Im::Error.new("#{abspath} is shadowed") if shadowed_file?(abspath) 134 | 135 | cget(namespace, base_cname) 136 | end 137 | 138 | # The caller is responsible for making sure `namespace` is the namespace that 139 | # corresponds to `dir`. 140 | # 141 | # @sig (String, Module, Boolean) -> void 142 | private def actual_eager_load_dir(dir, namespace, force: false) 143 | honour_exclusions = !force 144 | return if honour_exclusions && excluded_from_eager_load?(dir) 145 | 146 | log("eager load directory #{dir} start") if logger 147 | 148 | queue = [[dir, namespace]] 149 | while to_eager_load = queue.shift 150 | dir, namespace = to_eager_load 151 | 152 | ls(dir) do |basename, abspath| 153 | next if honour_exclusions && eager_load_exclusions.member?(abspath) 154 | 155 | if ruby?(abspath) 156 | if (cref = autoloads[abspath]) && !shadowed_file?(abspath) 157 | cget(*cref) 158 | end 159 | else 160 | if collapse?(abspath) 161 | queue << [abspath, namespace] 162 | else 163 | cname = inflector.camelize(basename, abspath).to_sym 164 | queue << [abspath, cget(namespace, cname)] 165 | end 166 | end 167 | end 168 | end 169 | 170 | log("eager load directory #{dir} end") if logger 171 | end 172 | 173 | # In order to invoke this method, the caller has to ensure `child` is a 174 | # strict namespace descendant of `root_namespace`. 175 | # 176 | # @sig (Module, String, Module, Boolean) -> void 177 | private def eager_load_child_namespace(child, child_name, root_dir) 178 | suffix = child_name 179 | suffix = suffix.delete_prefix("#{self}::") 180 | 181 | # These directories are at the same namespace level, there may be more if 182 | # we find collapsed ones. As we scan, we look for matches for the first 183 | # segment, and store them in `next_dirs`. If there are any, we look for 184 | # the next segments in those matches. Repeat. 185 | # 186 | # If we exhaust the search locating directories that match all segments, 187 | # we just need to eager load those ones. 188 | dirs = [root_dir] 189 | next_dirs = [] 190 | 191 | suffix.split("::").each do |segment| 192 | while dir = dirs.shift 193 | ls(dir) do |basename, abspath| 194 | next unless dir?(abspath) 195 | 196 | if collapse?(abspath) 197 | dirs << abspath 198 | elsif segment == inflector.camelize(basename, abspath) 199 | next_dirs << abspath 200 | end 201 | end 202 | end 203 | 204 | return if next_dirs.empty? 205 | 206 | dirs.replace(next_dirs) 207 | next_dirs.clear 208 | end 209 | 210 | dirs.each do |dir| 211 | actual_eager_load_dir(dir, child) 212 | end 213 | end 214 | end 215 | -------------------------------------------------------------------------------- /test/lib/im/test_for_gem.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class TestForGem < LoaderTest 6 | MY_GEM = ["lib/my_gem.rb", <<~EOS] 7 | $for_gem_test_loader = Im::Loader.for_gem 8 | $for_gem_test_loader.enable_reloading 9 | $for_gem_test_loader.setup 10 | 11 | module $for_gem_test_loader::MyGem 12 | end 13 | EOS 14 | 15 | def with_my_gem(files = [MY_GEM], rq = true) 16 | with_files(files) do 17 | with_load_path("lib") do 18 | if rq 19 | assert require("my_gem") 20 | assert loader::MyGem 21 | end 22 | yield 23 | end 24 | end 25 | end 26 | 27 | def loader 28 | Im::Registry.paths[$:.resolve_feature_path("my_gem")[1]] 29 | end 30 | 31 | test "sets things correctly" do 32 | files = [ 33 | MY_GEM, 34 | ["lib/my_gem/foo.rb", "class MyGem::Foo; end"], 35 | ["lib/my_gem/foo/bar.rb", "MyGem::Foo::Bar = true"] 36 | ] 37 | with_my_gem(files) do 38 | assert loader::MyGem::Foo::Bar 39 | 40 | loader.unload 41 | assert !loader.const_defined?(:MyGem) 42 | 43 | loader.setup 44 | assert loader::MyGem::Foo::Bar 45 | end 46 | end 47 | 48 | test "is idempotent" do 49 | $for_gem_test_zs = [] 50 | files = [ 51 | ["lib/my_gem.rb", <<-EOS], 52 | $for_gem_test_zs << Im::Loader.for_gem 53 | $for_gem_test_zs.last.enable_reloading 54 | $for_gem_test_zs.last.setup 55 | 56 | module $for_gem_test_zs.last::MyGem 57 | end 58 | EOS 59 | ] 60 | 61 | with_my_gem(files) do 62 | loader.unload 63 | assert !loader.const_defined?(:MyGem) 64 | 65 | loader.setup 66 | assert loader::MyGem 67 | 68 | assert_equal 2, $for_gem_test_zs.size 69 | assert_same $for_gem_test_zs.first, $for_gem_test_zs.last 70 | end 71 | end 72 | 73 | test "configures the gem inflector by default" do 74 | with_my_gem do 75 | assert_instance_of Im::GemInflector, loader.inflector 76 | end 77 | end 78 | 79 | test "configures the basename of the root file as loader tag" do 80 | with_my_gem do 81 | assert_equal "my_gem", loader.tag 82 | end 83 | end 84 | 85 | test "does not warn if lib only has expected files" do 86 | with_my_gem([MY_GEM], false) do 87 | assert_silent do 88 | assert require("my_gem") 89 | end 90 | end 91 | end 92 | 93 | test "does not warn if lib only has extra, non-hidden, non-Ruby files" do 94 | files = [MY_GEM, ["lib/i18n.yml", ""], ["lib/.vscode", ""]] 95 | with_my_gem(files, false) do 96 | assert_silent do 97 | assert require("my_gem") 98 | end 99 | end 100 | end 101 | 102 | test "warns if the lib has an extra Ruby file" do 103 | files = [MY_GEM, ["lib/foo.rb", ""]] 104 | with_my_gem(files, false) do 105 | _out, err = capture_io do 106 | assert require("my_gem") 107 | end 108 | assert_includes err, "Im defines the constant Foo after the file" 109 | assert_includes err, File.expand_path("lib/foo.rb") 110 | assert_includes err, "Im::Loader.for_gem(warn_on_extra_files: false)" 111 | end 112 | end 113 | 114 | test "does not warn if lib has an extra Ruby file, but it is ignored" do 115 | files = [["lib/my_gem.rb", <<~EOS], ["lib/foo.rb", ""]] 116 | loader = Im::Loader.for_gem 117 | loader.ignore("\#{__dir__}/foo.rb") 118 | loader.enable_reloading 119 | loader.setup 120 | 121 | module loader::MyGem 122 | end 123 | EOS 124 | with_my_gem(files, false) do 125 | _out, err = capture_io do 126 | assert require("my_gem") 127 | end 128 | assert_empty err 129 | end 130 | end 131 | 132 | test "does not warn if lib has an extra Ruby file, but warnings are disabled" do 133 | files = [["lib/my_gem.rb", <<~EOS], ["lib/foo.rb", ""]] 134 | loader = Im::Loader.for_gem(warn_on_extra_files: false) 135 | loader.enable_reloading 136 | loader.setup 137 | 138 | module loader::MyGem 139 | end 140 | EOS 141 | with_my_gem(files, false) do 142 | _out, err = capture_io do 143 | assert require("my_gem") 144 | end 145 | assert_empty err 146 | end 147 | end 148 | 149 | test "warns if lib has an extra directory" do 150 | files = [MY_GEM, ["lib/foo/bar.rb", "Foo::Bar = true"]] 151 | with_my_gem(files, false) do 152 | _out, err = capture_io do 153 | assert require("my_gem") 154 | end 155 | assert_includes err, "Im defines the constant Foo after the directory" 156 | assert_includes err, File.expand_path("lib/foo") 157 | assert_includes err, "Im::Loader.for_gem(warn_on_extra_files: false)" 158 | end 159 | end 160 | 161 | test "does not warn if lib has an extra directory, but it is ignored" do 162 | files = [["lib/my_gem.rb", <<~EOS], ["lib/foo/bar.rb", "Foo::Bar = true"]] 163 | loader = Im::Loader.for_gem 164 | loader.ignore("\#{__dir__}/foo") 165 | loader.enable_reloading 166 | loader.setup 167 | 168 | module loader::MyGem 169 | end 170 | EOS 171 | with_my_gem(files, false) do 172 | _out, err = capture_io do 173 | assert require("my_gem") 174 | end 175 | assert_empty err 176 | end 177 | end 178 | 179 | test "does not warn if lib has an extra directory, but it has no Ruby files" do 180 | files = [["lib/my_gem.rb", <<~EOS], ["lib/tasks/newsletter.rake", ""]] 181 | loader = Im::Loader.for_gem 182 | loader.enable_reloading 183 | loader.setup 184 | 185 | module loader::MyGem 186 | end 187 | EOS 188 | with_my_gem(files, false) do 189 | _out, err = capture_io do 190 | assert require("my_gem") 191 | end 192 | assert_empty err 193 | end 194 | end 195 | 196 | test "does not warn if lib has an extra directory, but warnings are disabled" do 197 | files = [["lib/my_gem.rb", <<~EOS], ["lib/foo/bar.rb", "Foo::Bar = true"]] 198 | loader = Im::Loader.for_gem(warn_on_extra_files: false) 199 | loader.enable_reloading 200 | loader.setup 201 | 202 | module loader::MyGem 203 | end 204 | EOS 205 | with_my_gem(files, false) do 206 | _out, err = capture_io do 207 | assert require("my_gem") 208 | end 209 | assert_empty err 210 | end 211 | end 212 | 213 | test "warnings do not assume the namespace directory is the tag" do 214 | files = [["lib/my_gem.rb", <<~EOS], ["lib/foo/bar.rb", "Foo::Bar = true"]] 215 | loader = Im::Loader.for_gem 216 | loader.tag = "foo" 217 | loader.enable_reloading 218 | loader.setup 219 | 220 | module loader::MyGem 221 | end 222 | EOS 223 | with_my_gem(files, false) do 224 | _out, err = capture_io do 225 | assert require("my_gem") 226 | end 227 | assert_includes err, "Im defines the constant Foo after the directory" 228 | assert_includes err, File.expand_path("lib/foo") 229 | assert_includes err, "Im::Loader.for_gem(warn_on_extra_files: false)" 230 | end 231 | end 232 | 233 | test "warnings use the gem inflector" do 234 | files = [["lib/my_gem.rb", <<~EOS], ["lib/foo/bar.rb", "Foo::Bar = true"]] 235 | loader = Im::Loader.for_gem 236 | loader.inflector.inflect("foo" => "BAR") 237 | loader.enable_reloading 238 | loader.setup 239 | 240 | module loader::MyGem 241 | end 242 | EOS 243 | with_my_gem(files, false) do 244 | _out, err = capture_io do 245 | assert require("my_gem") 246 | end 247 | assert_includes err, "Im defines the constant BAR after the directory" 248 | assert_includes err, File.expand_path("lib/foo") 249 | assert_includes err, "Im::Loader.for_gem(warn_on_extra_files: false)" 250 | end 251 | end 252 | end 253 | -------------------------------------------------------------------------------- /test/lib/im/test_eager_load_dir.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "pathname" 4 | require "test_helper" 5 | 6 | class TestEagerLoadDir < LoaderTest 7 | test "eager loads all files" do 8 | files = [ 9 | ["x.rb", "X = 1"], 10 | ["y.rb", "Y = 1"], 11 | ["m/n/p.rb", "module M::N::P; end"], 12 | ["m/n/a.rb", "M::N::A = 1"], 13 | ["m/n/p/q/z.rb", "M::N::P::Q::Z = 1"] 14 | ] 15 | with_setup(files) do 16 | loader.eager_load_dir(".") 17 | 18 | files.each do |file| 19 | assert required?(file) 20 | end 21 | end 22 | end 23 | 24 | test "eager loads all files (Pathname)" do 25 | files = [ 26 | ["x.rb", "X = 1"], 27 | ["y.rb", "Y = 1"], 28 | ["m/n/p.rb", "module M::N::P; end"], 29 | ["m/n/a.rb", "M::N::A = 1"], 30 | ["m/n/p/q/z.rb", "M::N::P::Q::Z = 1"] 31 | ] 32 | with_setup(files) do 33 | loader.eager_load_dir(Pathname.new(".")) 34 | assert required?(files) 35 | end 36 | end 37 | 38 | test "does not eager load excluded files or directories" do 39 | files = [ 40 | ["x.rb", "X = 1"], 41 | ["y.rb", "Y = 1"], 42 | ["m/n.rb", "module M::N; end"], 43 | ["m/n/a.rb", "M::N::A = 1"] 44 | ] 45 | with_setup(files) do 46 | loader.do_not_eager_load("y.rb") 47 | loader.do_not_eager_load("m/n") 48 | loader.eager_load_dir(".") 49 | 50 | assert required?(files[0]) 51 | assert !required?(files[1]) 52 | assert required?(files[2]) 53 | assert !required?(files[3]) 54 | end 55 | end 56 | 57 | test "does not eager load excluded files or directories (descendants)" do 58 | files = [ 59 | ["excluded/m/n.rb", "module M::N; end"], 60 | ["excluded/m/n/a.rb", "M::N::A = 1"] 61 | ] 62 | with_setup(files) do 63 | loader.do_not_eager_load("excluded") 64 | loader.eager_load_dir(".") 65 | 66 | assert files.none? { |file| required?(file) } 67 | end 68 | end 69 | 70 | # This is a file system-based interface. 71 | test "eager loads excluded explicit namespaces if some subtree is not excluded" do 72 | files = [ 73 | ["x.rb", "X = 1"], 74 | ["m/n.rb", "module M::N; end"], 75 | ["m/n/a.rb", "M::N::A = 1"] 76 | ] 77 | with_setup(files) do 78 | loader.do_not_eager_load("m/n.rb") 79 | loader.eager_load_dir(".") 80 | 81 | assert required?(files) 82 | end 83 | end 84 | 85 | test "does not load intermediate files if the target is an excluded descendant" do 86 | files = [["excluded.rb", "module Excluded; end"], ["excluded/n/x.rb", "EXCLUDED"]] 87 | with_setup(files) do 88 | loader.do_not_eager_load("excluded") 89 | loader.eager_load_dir("excluded/n") 90 | 91 | assert !required?(files[0]) 92 | end 93 | end 94 | 95 | test "does not eager load descendant ignored files or directories" do 96 | files = [ 97 | ["x.rb", "X = 1"], 98 | ["y.rb", "IGNORED"], 99 | ["m/n.rb", "module M::N; end"], 100 | ["m/n/a.rb", "IGNORED"] 101 | ] 102 | with_files(files) do 103 | loader.push_dir(".") 104 | loader.ignore("y.rb") 105 | loader.ignore("m/n") 106 | loader.setup 107 | loader.eager_load_dir(".") 108 | 109 | assert required?(files[0]) 110 | assert !required?(files[1]) 111 | assert required?(files[2]) 112 | assert !required?(files[3]) 113 | end 114 | end 115 | 116 | test "does not eager load shadowed files" do 117 | files = [ 118 | ["rd1/x.rb", "X = 1"], 119 | ["rd2/x.rb", "SHADOWED"] 120 | ] 121 | with_setup(files) do 122 | loader.eager_load_dir("rd2") 123 | 124 | assert !required?(files[0]) 125 | assert !required?(files[1]) 126 | end 127 | end 128 | 129 | test "eager loads all files in a subdirectory, ignoring what is above" do 130 | files = [ 131 | ["x.rb", "IGNORED"], 132 | ["m/k/x.rb", "IGNORED"], 133 | ["m/n/p.rb", "module M::N::P; end"], 134 | ["m/n/a.rb", "M::N::A = 1"], 135 | ["m/n/p/q/z.rb", "M::N::P::Q::Z = 1"] 136 | ] 137 | with_setup(files) do 138 | loader.eager_load_dir("m/n") 139 | 140 | assert !required?(files[0]) 141 | assert !required?(files[1]) 142 | assert required?(files[2]) 143 | assert required?(files[3]) 144 | assert required?(files[4]) 145 | end 146 | end 147 | 148 | test "eager loads all files, ignoring other directories (different namespace)" do 149 | files = [ 150 | ["a/x.rb", "A::X = 1"], 151 | ["b/y.rb", "B::Y = 1"], 152 | ["c/z.rb", "C::Z = 1"] 153 | ] 154 | with_setup(files) do 155 | loader.eager_load_dir("a") 156 | 157 | assert required?(files[0]) 158 | assert !required?(files[1]) 159 | assert !required?(files[2]) 160 | end 161 | end 162 | 163 | test "eager loads all files, ignoring other directories (same namespace)" do 164 | files = [ 165 | ["rd1/m/x.rb", "M::X = 1"], 166 | ["rd2/m/y.rb", "M::Y = 1"], 167 | ] 168 | with_setup(files) do 169 | loader.eager_load_dir("rd1/m") 170 | 171 | assert required?(files[0]) 172 | assert !required?(files[1]) 173 | end 174 | end 175 | 176 | # This is a file system-based interface. 177 | test "eager loads collapsed directories, ignoring the rest of the namespace" do 178 | files = [["x.rb", "X = 1"], ["collapsed/y.rb", "Y = 1"]] 179 | with_setup(files) do 180 | loader.eager_load_dir("collapsed") 181 | 182 | assert !required?(files[0]) 183 | assert required?(files[1]) 184 | end 185 | end 186 | 187 | test "does not eager load ignored directories (same)" do 188 | files = [["ignored/x.rb", "IGNORED"]] 189 | with_setup(files) do 190 | loader.eager_load_dir("ignored") 191 | 192 | assert !required?(files[0]) 193 | end 194 | end 195 | 196 | test "does not eager load if the argument is an ignored directory (descendant)" do 197 | files = [["ignored/m/x.rb", "IGNORED"]] 198 | with_setup(files) do 199 | loader.eager_load_dir("ignored/m") 200 | 201 | assert !required?(files[0]) 202 | end 203 | end 204 | 205 | test "files under nested root directories are ignored" do 206 | files = [ 207 | ["x.rb", "X = 1"], 208 | ["nested_root/y.rb", "Y = 1"] 209 | ] 210 | with_setup(files, dirs: %w(. nested_root)) do 211 | loader.eager_load_dir(".") 212 | 213 | assert required?(files[0]) 214 | assert !required?(files[1]) 215 | end 216 | end 217 | 218 | test "files under a root directory are loaded even if it has an ignored ascendant" do 219 | files = [ 220 | ["x.rb", "X = 1"], 221 | ["ignored/x.rb", "IGNORED"], 222 | ["ignored/nested-rd/y.rb", "Y = 1"] 223 | ] 224 | with_setup(files, dirs: %w(. ignored/nested-rd)) do 225 | loader.eager_load_dir("ignored/nested-rd") 226 | 227 | assert !required?(files[0]) 228 | assert !required?(files[1]) 229 | assert required?(files[2]) 230 | end 231 | end 232 | 233 | test "can be called recursively" do 234 | $test_loader = loader 235 | files = [ 236 | ["a/x.rb", "A::X = 1; $test_loader.eager_load_dir('b')"], 237 | ["b/x.rb", "B::X = 1"] 238 | ] 239 | with_setup(files) do 240 | loader.eager_load_dir("a") 241 | 242 | assert files.all? { |file| required?(file) } 243 | end 244 | end 245 | 246 | test "does not prevent reload" do 247 | $test_loaded_count = 0 248 | files = [["m/x.rb", "$test_loaded_count += 1; M::X = 1"]] 249 | with_setup(files) do 250 | loader.eager_load_dir("m") 251 | assert_equal 1, $test_loaded_count 252 | 253 | loader.reload 254 | 255 | loader.eager_load_dir("m") 256 | assert_equal 2, $test_loaded_count 257 | end 258 | end 259 | 260 | test "non-Ruby files are just ignored" do 261 | files = [ 262 | ["x.rb", "X = 1"], 263 | ["README.md", ""], 264 | ["TODO.txt", ""], 265 | [".config", ""], 266 | ] 267 | with_setup(files) do 268 | loader.eager_load_dir(".") 269 | 270 | assert required?(files[0]) 271 | end 272 | end 273 | 274 | test "shortcircuits if eager loaded" do 275 | files = [["x.rb", "X = 1"]] 276 | with_setup(files) do 277 | loader.eager_load 278 | 279 | # Dirty way to prove we shortcircuit. 280 | def loader.actual_eager_load_dir(*) 281 | raise 282 | end 283 | 284 | begin 285 | loader.eager_load_dir(".") 286 | rescue 287 | flunk 288 | else 289 | pass 290 | end 291 | end 292 | end 293 | 294 | test "raises Im::Error if the argument is not a directory" do 295 | with_setup do 296 | e = assert_raises(Im::Error) { loader.eager_load_dir(__FILE__) } 297 | assert_equal "#{__FILE__} is not a directory", e.message 298 | end 299 | end 300 | 301 | test "raises if the argument is not managed by the loader" do 302 | with_setup do 303 | e = assert_raises(Im::Error) { loader.eager_load_dir(__dir__) } 304 | assert_equal "I do not manage #{__dir__}", e.message 305 | end 306 | end 307 | 308 | test "raises if called before setup" do 309 | assert_raises(Im::SetupRequired) do 310 | loader.eager_load_dir(__dir__) 311 | end 312 | end 313 | end 314 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Im 2 | 3 | [![Gem Version](https://badge.fury.io/rb/im.svg)][gem] 4 | [![Build Status](https://github.com/shioyama/im/actions/workflows/ci.yml/badge.svg)][actions] 5 | 6 | [gem]: https://rubygems.org/gems/im 7 | [actions]: https://github.com/shioyama/im/actions 8 | 9 | 10 | 11 | - [Introduction](#introduction) 12 | - [Synopsis](#synopsis) 13 | - [File structure](#file-structure) 14 | - [File paths match constant paths under loader](#file-paths-match-constant-paths-under-loader) 15 | - [Root directories](#root-directories) 16 | - [Relative and absolute cpaths](#relative-and-absolute-cpaths) 17 | - [Usage](#usage) 18 | - [Motivation](#motivation) 19 | - [License](#license) 20 | 21 | 22 | 23 | 24 | ## Introduction 25 | 26 | Im is a thread-safe code loader for anonymous-rooted namespaces in Ruby. It 27 | allows you to share any nested, autoloaded set of code without polluting or in 28 | any way touching the global namespace. 29 | 30 | To do this, Im leverages code autoloading, Zeitwerk conventions around file 31 | structure and naming, and two features added in Ruby 3.2: `Kernel#load` 32 | with a module argument[^1] and `Module#const_added`[^2]. Since these Ruby 33 | features are essential to its design, Im is not usable with earlier versions 34 | of Ruby. 35 | 36 | Im started its life as a fork of Zeitwerk and has a very similar interface. Im 37 | and Zeitwerk can be used alongside each other provided there is no overlap 38 | between file paths managed by each gem. 39 | 40 | Im is in active development and should be considered experimental until the 41 | eventual release of version 1.0. Versions 0.1.6 and earlier of the gem were 42 | part of a different experiment and are unrelated to the current gem. 43 | 44 | 45 | ## Synopsis 46 | 47 | Im's public interface is in most respects identical to that of Zeitwerk. The 48 | central difference is that whereas Zeitwerk loads constants into the global 49 | namespace (rooted in `Object`), Im loads them into anonymous namespaces rooted 50 | on the loader itself. `Im::Loader` is a subclass of `Module`, and thus each 51 | loader instance can define its own namespace. Since there can be arbitrarily 52 | many loaders, there can also be arbitrarily many autoloaded namespaces. 53 | 54 | Im's gem interface looks like this: 55 | 56 | ```ruby 57 | # lib/my_gem.rb (main file) 58 | 59 | require "im" 60 | loader = Im::Loader.for_gem 61 | loader.setup # ready! 62 | 63 | module loader::MyGem 64 | # ... 65 | end 66 | 67 | loader.eager_load # optionally 68 | ``` 69 | 70 | The generic interface is identical to Zeitwerk's: 71 | 72 | ```ruby 73 | loader = Zeitwerk::Loader.new 74 | loader.push_dir(...) 75 | loader.setup # ready! 76 | ``` 77 | 78 | Other than gem names, the only difference here is in the definition of `MyGem` 79 | under the loader namespace in the gem code. Unlike Zeitwerk, with Im the gem 80 | namespace is not defined at toplevel: 81 | 82 | ```ruby 83 | Object.const_defined?(:MyGem) 84 | #=> false 85 | ``` 86 | 87 | In order to prevent leakage, the gem's entrypoint, in this case 88 | `lib/my_gem.rb`, must not define anything at toplevel, hence the use of 89 | `module loader::MyGem`. 90 | 91 | Once the entrypoint has been required, all constants defined within the gem's 92 | file structure are autoloadable from the loader itself: 93 | 94 | ```ruby 95 | # lib/my_gem/foo.rb 96 | 97 | module MyGem 98 | class Foo 99 | def hello_world 100 | "Hello World!" 101 | end 102 | end 103 | end 104 | ``` 105 | 106 | ```ruby 107 | foo = loader::MyGem::Foo 108 | # loads `Foo` from lib/my_gem/foo.rb 109 | 110 | foo.new.hello_world 111 | #=> "Hello World!" 112 | ``` 113 | 114 | Constants under the loader can be given permanent names that are different from 115 | the one defined in the gem itself: 116 | 117 | ```ruby 118 | Bar = loader::MyGem::Foo 119 | Bar.new.hello_world 120 | #=> "Hello World!" 121 | ``` 122 | 123 | Like Zeitwerk, Im keeps a registry of all loaders, so the loader objects won't 124 | be garbage collected. For convenience, Im also provides a method, `Im#import`, 125 | to fetch a loader for a given file path: 126 | 127 | ```ruby 128 | require "im" 129 | require "my_gem" 130 | 131 | extend Im 132 | my_gem = import "my_gem" 133 | #=> my_gem::MyGem is autoloadable 134 | ``` 135 | 136 | Reloading works like Zeitwerk: 137 | 138 | ```ruby 139 | loader = Im::Loader.new 140 | loader.push_dir(...) 141 | loader.enable_reloading # you need to opt-in before setup 142 | loader.setup 143 | ... 144 | loader.reload 145 | ``` 146 | 147 | You can assign a permanent name to an autoloaded constant, and it will be 148 | reloaded when the loader is reloaded: 149 | 150 | ```ruby 151 | Foo = loader::Foo 152 | loader.reload # Object::Foo is replaced by an autoload 153 | Foo #=> autoload is triggered, reloading loader::Foo 154 | ``` 155 | 156 | Like Zeitwerk, you can eager-load all the code at once: 157 | 158 | ```ruby 159 | loader.eager_load 160 | ``` 161 | 162 | Alternatively, you can broadcast `eager_load` to all loader instances: 163 | 164 | ```ruby 165 | Im::Loader.eager_load_all 166 | ``` 167 | 168 | 169 | ## File structure 170 | 171 | 172 | ### File paths match constant paths under loader 173 | 174 | File structure is identical to Zeitwerk, again with the difference that 175 | constants are loaded from the loader's namespace rather than the root one: 176 | 177 | ``` 178 | lib/my_gem.rb -> loader::MyGem 179 | lib/my_gem/foo.rb -> loader::MyGem::Foo 180 | lib/my_gem/bar_baz.rb -> loader::MyGem::BarBaz 181 | lib/my_gem/woo/zoo.rb -> loader::MyGem::Woo::Zoo 182 | ``` 183 | 184 | Im inherits support for collapsing directories and custom inflection, see 185 | Zeitwerk's documentation for details on usage of these features. 186 | 187 | 188 | ### Root directories 189 | 190 | Internally, each loader in Im can have one or more _root directories_ from which 191 | it loads code onto itself. Root directories are added to the loader using 192 | `Im::Loader#push_dir`: 193 | 194 | ```ruby 195 | loader.push_dir("#{__dir__}/models") 196 | loader.push_dir("#{__dir__}/serializers")) 197 | ``` 198 | 199 | Note that concept of a _root namespace_, which Zeitwerk uses to load code 200 | under a given node of the global namespace, is absent in Im. Custom root 201 | namespaces are likewise not supported. These features were removed as they add 202 | complexity for little gain given Im's flexibility to anchor a namespace 203 | anywhere in the global namespace. 204 | 205 | 206 | ### Relative and absolute cpaths 207 | 208 | Im uses two types of constant paths: relative and absolute, wherever possible 209 | defaulting to relative ones. A _relative cpath_ is a constant name relative to 210 | the loader in which it was originally defined, regardless of any other names it 211 | was later assigned. Whereas Zeitwerk uses absolute cpaths, Im uses relative 212 | cpaths for all external loader APIs (see usage for examples). 213 | 214 | To understand these concepts, it is important first to distinguish between two 215 | types of names in Ruby: _temporary names_ and _permanent names_. 216 | 217 | A _temporary name_ is a constant name on an anonymous-rooted namespace, for 218 | example a loader: 219 | 220 | ```ruby 221 | my_gem = import "my_gem" 222 | my_gem::Foo 223 | my_gem::Foo.name 224 | #=> "#::Foo" 225 | ``` 226 | 227 | Here, the string `"#::Foo"` is called a temporary name. We can 228 | give this module a _permanent name_ by assigning it to a toplevel constant: 229 | 230 | ```ruby 231 | Bar = my_gem::Foo 232 | my_gem::Foo.name 233 | #=> "Bar" 234 | ``` 235 | 236 | Now its name is `"Bar"`, and it is near impossible to get back its original 237 | temporary name. 238 | 239 | This property of module naming in Ruby is problematic since cpaths are used as 240 | keys in Im's internal registries to index constants and their autoloads, which 241 | is critical for successful autoloading. 242 | 243 | To get around this issue, Im tracks all module names and uses relative naming 244 | inside loader code. Internally, Im has a method, `relative_cpath`, which can 245 | generate any module name under a module in the loader namespace: 246 | 247 | ```ruby 248 | my_gem.send(:relative_cpath, loader::Foo, :Baz) 249 | #=> "Foo::Baz" 250 | ``` 251 | 252 | Using relative cpaths frees Im from depending on `Module#name` for 253 | registry keys like Zeitwerk does, which does not work with anonymous 254 | namespaces. All public methods in Im that take a `cpath` take the _relative_ 255 | cpath, i.e. the cpath relative to the loader as toplevel, regardless of any 256 | toplevel-rooted constant a module may have been assigned to. 257 | 258 | 259 | ## Usage 260 | 261 | (TODO) 262 | 263 | 264 | ## Motivation 265 | 266 | (TODO) 267 | 268 | ## Related 269 | 270 | - [Demo Rails app](https://github.com/shioyama/rails_on_im) using Im to isolate 271 | the application under one namespace 272 | 273 | 274 | ## License 275 | 276 | Released under the MIT License, Copyright (c) 2023 Chris Salzberg and 2019–ω Xavier Noria. 277 | 278 | [^1]: https://bugs.ruby-lang.org/issues/6210 279 | [^2]: https://bugs.ruby-lang.org/issues/17881 280 | -------------------------------------------------------------------------------- /test/lib/im/test_eager_load.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "fileutils" 5 | 6 | class TestEagerLoad < LoaderTest 7 | test "eager loads dependent loaders" do 8 | $test_eager_load_loaders = loaders = [loader, new_loader(setup: false)] 9 | 10 | files = [ 11 | ["lib0/app0.rb", <<-EOS], 12 | module App0 13 | $test_eager_load_loaders[1]::App1 14 | end 15 | EOS 16 | ["lib0/app0/foo.rb", <<-EOS], 17 | class App0::Foo 18 | $test_eager_load_loaders[1]::App1::Foo 19 | end 20 | EOS 21 | ["lib1/app1/foo.rb", <<-EOS], 22 | class App1::Foo 23 | $test_eager_load_loaders[0]::App0 24 | end 25 | EOS 26 | ["lib1/app1/foo/bar/baz.rb", <<-EOS] 27 | class App1::Foo::Bar::Baz 28 | $test_eager_load_loaders[0]::App0::Foo 29 | end 30 | EOS 31 | ] 32 | with_files(files) do 33 | loaders[0].push_dir("lib0") 34 | loaders[0].setup 35 | 36 | loaders[1].push_dir("lib1") 37 | loaders[1].setup 38 | 39 | Im::Loader.eager_load_all 40 | 41 | assert required?(files) 42 | end 43 | end 44 | 45 | test "skips loaders that are not ready" do 46 | files = [["x.rb", "X = 1"]] 47 | with_setup(files) do 48 | new_loader(setup: false) # should be skipped 49 | Im::Loader.eager_load_all 50 | assert required?(files) 51 | end 52 | end 53 | 54 | test "eager loads gems" do 55 | on_teardown do 56 | delete_loaded_feature "my_gem.rb" 57 | delete_loaded_feature "my_gem/foo.rb" 58 | delete_loaded_feature "my_gem/foo/bar.rb" 59 | delete_loaded_feature "my_gem/foo/baz.rb" 60 | end 61 | 62 | files = [ 63 | ["my_gem.rb", <<-EOS], 64 | $test_eager_load_loader = Im::Loader.for_gem 65 | $test_eager_load_loader.setup 66 | 67 | class $test_eager_load_loader::MyGem 68 | self::Foo::Baz # autoloads fine 69 | end 70 | 71 | $test_eager_load_loader.eager_load 72 | EOS 73 | ["my_gem/foo.rb", "class MyGem::Foo; end"], 74 | ["my_gem/foo/bar.rb", "class MyGem::Foo::Bar; end"], 75 | ["my_gem/foo/baz.rb", "class MyGem::Foo::Baz; end"], 76 | ] 77 | 78 | with_files(files) do 79 | with_load_path(".") do 80 | require "my_gem" 81 | assert required?(files) 82 | end 83 | end 84 | end 85 | 86 | [false, true].each do |enable_reloading| 87 | test "we can opt-out of entire root directories, and still autoload (enable_autoloading #{enable_reloading})" do 88 | on_teardown do 89 | delete_loaded_feature "foo.rb" 90 | end 91 | 92 | files = [["foo.rb", "Foo = true"]] 93 | with_files(files) do 94 | loader = new_loader(dirs: ".", enable_reloading: enable_reloading) 95 | loader.do_not_eager_load(".") 96 | loader.eager_load 97 | 98 | assert !required?(files[0]) 99 | assert loader::Foo 100 | end 101 | end 102 | 103 | test "we can opt-out of sudirectories, and still autoload (enable_autoloading #{enable_reloading})" do 104 | on_teardown do 105 | delete_loaded_feature "foo.rb" 106 | delete_loaded_feature "db_adapters/mysql_adapter.rb" 107 | end 108 | 109 | files = [ 110 | ["db_adapters/mysql_adapter.rb", <<-EOS], 111 | module DbAdapters::MysqlAdapter 112 | end 113 | EOS 114 | ["foo.rb", "Foo = true"] 115 | ] 116 | with_files(files) do 117 | loader = new_loader(dirs: ".", enable_reloading: enable_reloading) 118 | loader.do_not_eager_load("db_adapters") 119 | loader.eager_load 120 | 121 | assert !required?(files[0]) 122 | assert required?(files[1]) 123 | assert loader::DbAdapters::MysqlAdapter 124 | end 125 | end 126 | 127 | test "we can opt-out of files, and still autoload (enable_autoloading #{enable_reloading})" do 128 | on_teardown do 129 | delete_loaded_feature "foo.rb" 130 | delete_loaded_feature "bar.rb" 131 | end 132 | 133 | files = [ 134 | ["foo.rb", "Foo = true"], 135 | ["bar.rb", "Bar = true"] 136 | ] 137 | with_files(files) do 138 | loader = new_loader(dirs: ".", enable_reloading: enable_reloading) 139 | loader.do_not_eager_load("bar.rb") 140 | loader.eager_load 141 | 142 | assert required?(files[0]) 143 | assert !required?(files[1]) 144 | assert loader::Bar 145 | end 146 | end 147 | 148 | test "opt-ed out root directories sharing a namespace don't prevent autoload (enable_autoloading #{enable_reloading})" do 149 | on_teardown do 150 | delete_loaded_feature "ns/foo.rb" 151 | delete_loaded_feature "ns/bar.rb" 152 | end 153 | 154 | files = [ 155 | ["lazylib/ns/foo.rb", "module Ns::Foo; end"], 156 | ["eagerlib/ns/bar.rb", "module Ns::Bar; end"] 157 | ] 158 | with_files(files) do 159 | loader = new_loader(dirs: %w(lazylib eagerlib), enable_reloading: enable_reloading) 160 | loader.do_not_eager_load('lazylib') 161 | loader.eager_load 162 | 163 | assert !required?(files[0]) 164 | assert required?(files[1]) 165 | assert loader::Ns::Foo 166 | end 167 | end 168 | 169 | test "opt-ed out subdirectories don't prevent autoloading shared namespaces (enable_autoloading #{enable_reloading})" do 170 | on_teardown do 171 | delete_loaded_feature "ns/foo.rb" 172 | delete_loaded_feature "ns/bar.rb" 173 | end 174 | 175 | files = [ 176 | ["lazylib/ns/foo.rb", "module Ns::Foo; end"], 177 | ["eagerlib/ns/bar.rb", "module Ns::Bar; end"] 178 | ] 179 | with_files(files) do 180 | loader = new_loader(dirs: %w(lazylib eagerlib), enable_reloading: enable_reloading) 181 | loader.do_not_eager_load('lazylib/ns') 182 | loader.eager_load 183 | 184 | assert !required?(files[0]) 185 | assert required?(files[1]) 186 | assert loader::Ns::Foo 187 | end 188 | end 189 | end 190 | 191 | test "eager loading skips files that would map to already loaded constants" do 192 | files = [["x.rb", "X = 1"]] 193 | loader::X = 1 194 | with_setup(files) do 195 | loader.eager_load 196 | assert !required?(files[0]) 197 | end 198 | end 199 | 200 | test "eager loading works with symbolic links" do 201 | files = [["real/x.rb", "X = true"]] 202 | with_files(files) do 203 | FileUtils.ln_s("real", "symlink") 204 | loader.push_dir("symlink") 205 | loader.setup 206 | loader.eager_load 207 | 208 | assert_nil loader.autoload?(:X) 209 | end 210 | end 211 | 212 | test "force eager load for root directories" do 213 | files = [["foo.rb", "Foo = true"]] 214 | with_setup(files) do 215 | loader.do_not_eager_load(".") 216 | loader.eager_load(force: true) 217 | 218 | assert required?(files) 219 | end 220 | end 221 | 222 | test "force eager load for sudirectories" do 223 | files = [ 224 | ["db_adapters/mysql_adapter.rb", <<-EOS], 225 | module DbAdapters::MysqlAdapter 226 | end 227 | EOS 228 | ] 229 | with_setup(files) do 230 | loader.do_not_eager_load("db_adapters") 231 | loader.eager_load(force: true) 232 | 233 | assert required?(files) 234 | assert loader::DbAdapters::MysqlAdapter 235 | end 236 | end 237 | 238 | test "force eager load for root files" do 239 | files = [["foo.rb", "Foo = true"]] 240 | with_setup(files) do 241 | loader.do_not_eager_load("foo.rb") 242 | loader.eager_load(force: true) 243 | 244 | assert required?(files) 245 | end 246 | end 247 | 248 | test "force eager load for namespaced files" do 249 | files = [["m/foo.rb", "M::Foo = true"]] 250 | with_setup(files) do 251 | loader.do_not_eager_load("m/foo.rb") 252 | loader.eager_load(force: true) 253 | 254 | assert required?(files) 255 | end 256 | end 257 | 258 | test "force eager load honours ignored root directories" do 259 | files = [["ignored/foo.rb", "Foo = true"]] 260 | with_setup(files, dirs: %w(ignored)) do 261 | loader.eager_load(force: true) 262 | 263 | assert !required?(files) 264 | end 265 | end 266 | 267 | test "force eager load honours ignored subdirectories" do 268 | files = [["ignored/foo.rb", "IGNORED"]] 269 | with_setup(files) do 270 | loader.eager_load(force: true) 271 | 272 | assert !required?(files) 273 | end 274 | end 275 | 276 | test "force eager load honours root files" do 277 | files = [["ignored.rb", "IGNORED"]] 278 | with_setup(files) do 279 | loader.eager_load(force: true) 280 | 281 | assert !required?(files) 282 | end 283 | end 284 | 285 | test "force eager load honours namespaced files" do 286 | files = [["m/ignored.rb", "IGNORED"]] 287 | with_setup(files) do 288 | loader.eager_load(force: true) 289 | 290 | assert !required?(files) 291 | end 292 | end 293 | 294 | test "files are eager loaded in lexicographic order" do 295 | files = [["x.rb", "X = 1"], ["y.rb", "Y = 1"]] 296 | with_setup(files) do 297 | loaded = [] 298 | loader.on_load do |cpath, _value, _abspath| 299 | loaded << cpath 300 | end 301 | 302 | Dir.stub :children, ["y.rb", "x.rb"] do 303 | loader.eager_load 304 | end 305 | 306 | assert_equal ["X", "Y"], loaded 307 | end 308 | end 309 | 310 | test "raises if called before setup" do 311 | assert_raises(Im::SetupRequired) do 312 | loader.eager_load 313 | end 314 | end 315 | end 316 | -------------------------------------------------------------------------------- /lib/im/loader/config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "set" 4 | require "securerandom" 5 | 6 | module Im::Loader::Config 7 | extend Im::Internal 8 | 9 | # @sig #camelize 10 | attr_accessor :inflector 11 | 12 | # @sig #call | #debug | nil 13 | attr_accessor :logger 14 | 15 | # Absolute paths of the root directories, as a set: 16 | # 17 | # # 22 | # 23 | # This is a private collection maintained by the loader. The public 24 | # interface for it is `push_dir` and `dirs`. 25 | # 26 | # @sig Array[String] 27 | attr_reader :root_dirs 28 | internal :root_dirs 29 | 30 | # Absolute paths of files, directories, or glob patterns to be totally 31 | # ignored. 32 | # 33 | # @sig Set[String] 34 | attr_reader :ignored_glob_patterns 35 | private :ignored_glob_patterns 36 | 37 | # The actual collection of absolute file and directory names at the time the 38 | # ignored glob patterns were expanded. Computed on setup, and recomputed on 39 | # reload. 40 | # 41 | # @sig Set[String] 42 | attr_reader :ignored_paths 43 | private :ignored_paths 44 | 45 | # Absolute paths of directories or glob patterns to be collapsed. 46 | # 47 | # @sig Set[String] 48 | attr_reader :collapse_glob_patterns 49 | private :collapse_glob_patterns 50 | 51 | # The actual collection of absolute directory names at the time the collapse 52 | # glob patterns were expanded. Computed on setup, and recomputed on reload. 53 | # 54 | # @sig Set[String] 55 | attr_reader :collapse_dirs 56 | private :collapse_dirs 57 | 58 | # Absolute paths of files or directories not to be eager loaded. 59 | # 60 | # @sig Set[String] 61 | attr_reader :eager_load_exclusions 62 | private :eager_load_exclusions 63 | 64 | # User-oriented callbacks to be fired on setup and on reload. 65 | # 66 | # @sig Array[{ () -> void }] 67 | attr_reader :on_setup_callbacks 68 | private :on_setup_callbacks 69 | 70 | # User-oriented callbacks to be fired when a constant is loaded. 71 | # 72 | # @sig Hash[String, Array[{ (Object, String) -> void }]] 73 | # Hash[Symbol, Array[{ (String, Object, String) -> void }]] 74 | attr_reader :on_load_callbacks 75 | private :on_load_callbacks 76 | 77 | # User-oriented callbacks to be fired before constants are removed. 78 | # 79 | # @sig Hash[String, Array[{ (Object, String) -> void }]] 80 | # Hash[Symbol, Array[{ (String, Object, String) -> void }]] 81 | attr_reader :on_unload_callbacks 82 | private :on_unload_callbacks 83 | 84 | def initialize 85 | @inflector = Im::Inflector.new 86 | @logger = self.class.default_logger 87 | @tag = SecureRandom.hex(3) 88 | @initialized_at = Time.now 89 | @root_dirs = Set.new 90 | @ignored_glob_patterns = Set.new 91 | @ignored_paths = Set.new 92 | @collapse_glob_patterns = Set.new 93 | @collapse_dirs = Set.new 94 | @eager_load_exclusions = Set.new 95 | @reloading_enabled = false 96 | @on_setup_callbacks = [] 97 | @on_load_callbacks = {} 98 | @on_unload_callbacks = {} 99 | end 100 | 101 | # Pushes `path` to the list of root directories. 102 | # 103 | # Raises `Im::Error` if `path` does not exist, or if another loader in 104 | # the same process already manages that directory or one of its ascendants or 105 | # descendants. 106 | # 107 | # @raise [Im::Error] 108 | # @sig (String | Pathname, Module) -> void 109 | def push_dir(path) 110 | abspath = File.expand_path(path) 111 | if dir?(abspath) 112 | raise_if_conflicting_directory(abspath) 113 | root_dirs << abspath 114 | else 115 | raise Im::Error, "the root directory #{abspath} does not exist" 116 | end 117 | end 118 | 119 | # Returns the loader's tag. 120 | # 121 | # Implemented as a method instead of via attr_reader for symmetry with the 122 | # writer below. 123 | # 124 | # @sig () -> String 125 | def tag 126 | @tag 127 | end 128 | 129 | # Sets a tag for the loader, useful for logging. 130 | # 131 | # @sig (#to_s) -> void 132 | def tag=(tag) 133 | @tag = tag.to_s 134 | end 135 | 136 | # Rturns an array with the absolute paths of the root directories as strings. 137 | # If `ignored` is falsey (default), ignored root directories are filtered out. 138 | # 139 | # These are read-only collections, please add to them with `push_dir`. 140 | # 141 | # @sig () -> Array[String] | Hash[String, Module] 142 | def dirs(ignored: false) 143 | if ignored || ignored_paths.empty? 144 | root_dirs 145 | else 146 | root_dirs.reject { |root_dir| ignored_path?(root_dir) } 147 | end.freeze 148 | end 149 | 150 | # You need to call this method before setup in order to be able to reload. 151 | # There is no way to undo this, either you want to reload or you don't. 152 | # 153 | # @raise [Im::Error] 154 | # @sig () -> void 155 | def enable_reloading 156 | mutex.synchronize do 157 | break if @reloading_enabled 158 | 159 | if @setup 160 | raise Im::Error, "cannot enable reloading after setup" 161 | else 162 | @reloading_enabled = true 163 | end 164 | end 165 | end 166 | 167 | # @sig () -> bool 168 | def reloading_enabled? 169 | @reloading_enabled 170 | end 171 | 172 | # Let eager load ignore the given files or directories. The constants defined 173 | # in those files are still autoloadable. 174 | # 175 | # @sig (*(String | Pathname | Array[String | Pathname])) -> void 176 | def do_not_eager_load(*paths) 177 | mutex.synchronize { eager_load_exclusions.merge(expand_paths(paths)) } 178 | end 179 | 180 | # Configure files, directories, or glob patterns to be totally ignored. 181 | # 182 | # @sig (*(String | Pathname | Array[String | Pathname])) -> void 183 | def ignore(*glob_patterns) 184 | glob_patterns = expand_paths(glob_patterns) 185 | mutex.synchronize do 186 | ignored_glob_patterns.merge(glob_patterns) 187 | ignored_paths.merge(expand_glob_patterns(glob_patterns)) 188 | end 189 | end 190 | 191 | # Configure directories or glob patterns to be collapsed. 192 | # 193 | # @sig (*(String | Pathname | Array[String | Pathname])) -> void 194 | def collapse(*glob_patterns) 195 | glob_patterns = expand_paths(glob_patterns) 196 | mutex.synchronize do 197 | collapse_glob_patterns.merge(glob_patterns) 198 | collapse_dirs.merge(expand_glob_patterns(glob_patterns)) 199 | end 200 | end 201 | 202 | # Configure a block to be called after setup and on each reload. 203 | # If setup was already done, the block runs immediately. 204 | # 205 | # @sig () { () -> void } -> void 206 | def on_setup(&block) 207 | mutex.synchronize do 208 | on_setup_callbacks << block 209 | block.call if @setup 210 | end 211 | end 212 | 213 | # Configure a block to be invoked once a certain constant path is loaded. 214 | # Supports multiple callbacks, and if there are many, they are executed in 215 | # the order in which they were defined. 216 | # 217 | # loader.on_load("SomeApiClient") do |klass, _abspath| 218 | # klass.endpoint = "https://api.dev" 219 | # end 220 | # 221 | # Can also be configured for any constant loaded: 222 | # 223 | # loader.on_load do |cpath, value, abspath| 224 | # # ... 225 | # end 226 | # 227 | # @raise [TypeError] 228 | # @sig (String) { (Object, String) -> void } -> void 229 | # (:ANY) { (String, Object, String) -> void } -> void 230 | def on_load(cpath = :ANY, &block) 231 | raise TypeError, "on_load only accepts strings" unless cpath.is_a?(String) || cpath == :ANY 232 | 233 | mutex.synchronize { _on_load(cpath, &block) } 234 | end 235 | 236 | # @sig (String) { (Object, String) -> void } -> void 237 | # (:ANY) { (String, Object, String) -> void } -> void 238 | private def _on_load(cpath, &block) 239 | (on_load_callbacks[cpath] ||= []) << block 240 | end 241 | 242 | # Configure a block to be invoked right before a certain constant is removed. 243 | # Supports multiple callbacks, and if there are many, they are executed in the 244 | # order in which they were defined. 245 | # 246 | # loader.on_unload("Country") do |klass, _abspath| 247 | # klass.clear_cache 248 | # end 249 | # 250 | # Can also be configured for any removed constant: 251 | # 252 | # loader.on_unload do |cpath, value, abspath| 253 | # # ... 254 | # end 255 | # 256 | # @raise [TypeError] 257 | # @sig (String) { (Object) -> void } -> void 258 | # (:ANY) { (String, Object) -> void } -> void 259 | def on_unload(cpath = :ANY, &block) 260 | raise TypeError, "on_unload only accepts strings" unless cpath.is_a?(String) || cpath == :ANY 261 | 262 | mutex.synchronize do 263 | (on_unload_callbacks[cpath] ||= []) << block 264 | end 265 | end 266 | 267 | # Logs to `$stdout`, handy shortcut for debugging. 268 | # 269 | # @sig () -> void 270 | def log! 271 | @logger = ->(msg) { puts msg } 272 | end 273 | 274 | # Returns true if the argument has been configured to be ignored, or is a 275 | # descendant of an ignored directory. 276 | # 277 | # @sig (String) -> bool 278 | internal def ignores?(abspath) 279 | # Common use case. 280 | return false if ignored_paths.empty? 281 | 282 | walk_up(abspath) do |path| 283 | return true if ignored_path?(path) 284 | return false if root_dir?(path) 285 | end 286 | 287 | false 288 | end 289 | 290 | # @sig (String) -> bool 291 | private def ignored_path?(abspath) 292 | ignored_paths.member?(abspath) 293 | end 294 | 295 | # @sig () -> Array[String] 296 | private def actual_roots 297 | root_dirs.reject do |root_dir| 298 | !dir?(root_dir) || ignored_path?(root_dir) 299 | end 300 | end 301 | 302 | # @sig (String) -> bool 303 | private def root_dir?(dir) 304 | root_dirs.include?(dir) 305 | end 306 | 307 | # @sig (String) -> bool 308 | private def excluded_from_eager_load?(abspath) 309 | # Optimize this common use case. 310 | return false if eager_load_exclusions.empty? 311 | 312 | walk_up(abspath) do |path| 313 | return true if eager_load_exclusions.member?(path) 314 | return false if root_dir?(path) 315 | end 316 | 317 | false 318 | end 319 | 320 | # @sig (String) -> bool 321 | private def collapse?(dir) 322 | collapse_dirs.member?(dir) 323 | end 324 | 325 | # @sig (String | Pathname | Array[String | Pathname]) -> Array[String] 326 | private def expand_paths(paths) 327 | paths.flatten.map! { |path| File.expand_path(path) } 328 | end 329 | 330 | # @sig (Array[String]) -> Array[String] 331 | private def expand_glob_patterns(glob_patterns) 332 | # Note that Dir.glob works with regular file names just fine. That is, 333 | # glob patterns technically need no wildcards. 334 | glob_patterns.flat_map { |glob_pattern| Dir.glob(glob_pattern) } 335 | end 336 | 337 | # @sig () -> void 338 | private def recompute_ignored_paths 339 | ignored_paths.replace(expand_glob_patterns(ignored_glob_patterns)) 340 | end 341 | 342 | # @sig () -> void 343 | private def recompute_collapse_dirs 344 | collapse_dirs.replace(expand_glob_patterns(collapse_glob_patterns)) 345 | end 346 | end 347 | -------------------------------------------------------------------------------- /test/lib/im/test_ruby_compatibility.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "pathname" 5 | 6 | class TestRubyCompatibility < LoaderTest 7 | # We decorate Kernel#require in lib/im/kernel.rb be able to trigger 8 | # callbacks, autovivify implicit namespaces, keep track of what has been 9 | # autoloaded, and more. 10 | test "autoload calls Kernel#require" do 11 | files = [["x.rb", "X = true"]] 12 | with_files(files) do 13 | loader.push_dir(".") 14 | loader.setup 15 | 16 | $trc_require_has_been_called = false 17 | $trc_autoload_path = File.expand_path("x.rb") 18 | 19 | begin 20 | Kernel.module_eval do 21 | alias_method :trc_original_require, :require 22 | def require(path) 23 | $trc_require_has_been_called = true if path == $trc_autoload_path 24 | trc_original_require(path) 25 | end 26 | end 27 | 28 | assert loader::X 29 | assert $trc_require_has_been_called 30 | ensure 31 | Kernel.module_eval do 32 | remove_method :require 33 | define_method :require, instance_method(:trc_original_require) 34 | remove_method :trc_original_require 35 | end 36 | end 37 | end 38 | end 39 | 40 | # Once a managed file is autoloaded, Im verifies the expected constant 41 | # has been defined and raises Im::NameError if not. This happens within 42 | # the context of the require call and is correct because an autoload does not 43 | # define the constant by itself, it has to be a side-effect. 44 | test "within a file triggered by an autoload, the constant being autoloaded is not defined" do 45 | files = [["x.rb", "$const_defined_for_X = $trc_loader.const_defined?(:X); X = 1"]] 46 | $trc_loader = loader 47 | 48 | with_setup(files) do 49 | $const_defined_for_X = loader.const_defined?(:X) 50 | assert $const_defined_for_X 51 | assert loader::X 52 | assert !$const_defined_for_X 53 | end 54 | end 55 | 56 | # Im sets autoloads using absolute paths and string concatenation with 57 | # the root directories. These paths could contain symlinks, but we can still 58 | # identify managed files in our decorated Kernel#require because Ruby stores 59 | # the paths as they are in $LOADED_FEATURES with no symlink resolution. 60 | test "absolute paths passed to require end up in $LOADED_FEATURES as is" do 61 | on_teardown { $LOADED_FEATURES.pop } 62 | 63 | files = [["real/real_x.rb", ""]] 64 | with_files(files) do 65 | FileUtils.ln_s("real", "sym") 66 | FileUtils.ln_s(File.expand_path("real/real_x.rb"), "sym/sym_x.rb") 67 | 68 | sym_x = File.expand_path("sym/sym_x.rb") 69 | assert require(sym_x) 70 | assert $LOADED_FEATURES.last == sym_x 71 | end 72 | end 73 | 74 | # Im has to be called as soon as explicit namespaces are defined, to be 75 | # able to configure autoloads for their children before the class or module 76 | # body is interpreted. If explicit namespaces are found, Im sets a trace 77 | # point on the :class event with that purpose. 78 | # 79 | # This is key because the body of explicit namespaces could reference child 80 | # constants at the top-level. Mixins are a common use case. 81 | test "TracePoint emits :class events" do 82 | on_teardown do 83 | @tp.disable 84 | end 85 | 86 | called = false 87 | 88 | @tp = TracePoint.new(:class) { called = true } 89 | @tp.enable 90 | 91 | class loader::C; end 92 | assert called 93 | end 94 | 95 | # We configure autoloads on directories to autovivify modules on demand, and 96 | # lazily descend to set autoloads for their children. This is more efficient, 97 | # specially for large code bases. 98 | test "you can set autoloads on directories" do 99 | files = ["admin/users_controller.rb", "class UsersController; end"] 100 | with_setup(files) do 101 | assert_equal "#{Dir.pwd}/admin", loader.autoload?(:Admin) 102 | end 103 | end 104 | 105 | # While unloading constants we leverage this property to avoid lookups in 106 | # $LOADED_FEATURES for strings that we know are not going to be there. 107 | test "directories are not included in $LOADED_FEATURES" do 108 | with_files(["admin/users_controller.rb"]) do 109 | loader.push_dir(".") 110 | loader.setup 111 | 112 | assert loader::Admin 113 | assert !$LOADED_FEATURES.include?(File.expand_path("admin")) 114 | end 115 | end 116 | 117 | # We exploit this one to simplify the detection of explicit namespaces. 118 | # 119 | # Let's suppose `Admin` is an explicit namespace and scanning finds first a 120 | # directory called `admin`. We set at that point an autoload for `Admin` that 121 | # will require that directory. If later on, scanning finds `admin.rb`, we just 122 | # set the autoload again, and change the target file. 123 | # 124 | # This way, we do not need to keep state or do an a posteriori pass, can set 125 | # autoloads linearly as scanning progresses. 126 | test "an autoload can be overridden" do 127 | on_teardown { remove_const :X } 128 | 129 | files = [ 130 | ["x0/x.rb", "X = 0"], 131 | ["x1/x.rb", "X = 1"] 132 | ] 133 | with_files(files) do 134 | Object.autoload(:X, File.expand_path("x0/x.rb")) 135 | Object.autoload(:X, File.expand_path("x1/x.rb")) 136 | 137 | assert_equal 1, X 138 | end 139 | end 140 | 141 | # In some spots like shadowed files detection we need to check if constants 142 | # are already defined in the parent class or module. In order to do this and 143 | # still be lazy, we rely on this property of const_defined? 144 | # 145 | # This also matters for autoloads already set by 3rd-party code, for example 146 | # in reopened namespaces. Im won't override them, but thanks to this 147 | # characteristic of const_defined? if won't trigger them either. 148 | test "const_defined? is true for autoloads and does not load the file, if the file exists" do 149 | on_teardown { remove_const :X } 150 | 151 | files = [["x.rb", "$const_defined_does_not_trigger_autoload = false; X = true"]] 152 | with_files(files) do 153 | $const_defined_does_not_trigger_autoload = true 154 | Object.autoload(:X, File.expand_path("x.rb")) 155 | 156 | assert Object.const_defined?(:X, false) 157 | assert $const_defined_does_not_trigger_autoload 158 | end 159 | end 160 | 161 | # Unloading removes autoloads by calling remove_const. It is convenient that 162 | # remove_const does not execute the autoload because it would be surprising, 163 | # and slower, that those unused files got loaded precisely while unloading. 164 | test "remove_const does not trigger an autoload" do 165 | files = [["x.rb", "$remove_const_does_not_trigger_autoload = false; X = 1"]] 166 | with_files(files) do 167 | $remove_const_does_not_trigger_autoload = true 168 | loader.autoload(:X, File.expand_path("x.rb")) 169 | 170 | loader.send(:remove_const, :X) 171 | assert $remove_const_does_not_trigger_autoload 172 | end 173 | end 174 | 175 | # Loaders use this property when unloading to be able tell if the autoloads 176 | # that are pending according to their state are still pending. While things 177 | # are autoloaded that collection is maintained, this should not be needed. But 178 | # client code doing unsupported stuff like using require_relative on managed 179 | # files could introduce weird state we need to be defensive about. 180 | test "autoloading removes the autoload configuration in the parent" do 181 | on_teardown do 182 | remove_const :X 183 | delete_loaded_feature "x.rb" 184 | end 185 | 186 | files = [["x.rb", "X = true"]] 187 | with_files(files) do 188 | Object.autoload(:X, File.expand_path("x.rb")) 189 | 190 | assert Object.autoload?(:X) 191 | assert X 192 | assert !Object.autoload?(:X) 193 | end 194 | end 195 | 196 | # We use remove_const to delete autoload configurations while unloading. 197 | # Otherwise, the configured files or directories could become stale. 198 | test "autoload configuration can be deleted with remove_const" do 199 | files = [["x.rb", "X = true"]] 200 | with_files(files) do 201 | loader.autoload(:X, File.expand_path("x.rb")) 202 | 203 | assert loader.autoload?(:X) 204 | remove_const :X, from: loader 205 | assert !loader.autoload?(:X) 206 | end 207 | end 208 | 209 | # This edge case justifies the need for the inceptions collection in the 210 | # registry. See Im::Registry.inceptions. 211 | test "an autoload on yourself is ignored" do 212 | $trc_loader = loader 213 | files = [["foo.rb", <<-EOS]] 214 | $trc_loader.autoload(:Foo, __FILE__) 215 | $trc_inception = !$trc_loader.autoload?(:Foo) 216 | Foo = 1 217 | EOS 218 | with_files(files) do 219 | loader.push_dir(".") 220 | loader.setup 221 | 222 | with_load_path do 223 | $trc_inception = false 224 | require "foo" 225 | end 226 | 227 | assert $trc_inception 228 | end 229 | end 230 | 231 | # Same as above, adding some depth. 232 | test "an autoload on a file being required at some point up in the call chain is also ignored" do 233 | on_teardown { $trc_loader = nil } 234 | 235 | files = [ 236 | ["foo.rb", <<-EOS], 237 | require 'bar' 238 | Foo = 1 239 | EOS 240 | ["bar.rb", <<-EOS] 241 | Bar = true 242 | $trc_loader.autoload(:Foo, File.expand_path('foo.rb')) 243 | $trc_inception = !Object.autoload?(:Foo) 244 | EOS 245 | ] 246 | with_files(files) do 247 | $trc_loader = loader 248 | loader.push_dir(".") 249 | loader.setup 250 | 251 | with_load_path do 252 | $trc_inception = false 253 | require "foo" 254 | end 255 | 256 | assert $trc_inception 257 | end 258 | end 259 | 260 | # This is why we issue a namespace_dirs.delete call in the tracer block, to 261 | # ignore events triggered by reopenings. 262 | test "tracing :class calls you back on creation and on reopening" do 263 | on_teardown do 264 | @tracer.disable 265 | remove_const :C, from: self.class 266 | remove_const :M, from: self.class 267 | end 268 | 269 | traced = [] 270 | @tracer = TracePoint.trace(:class) do |tp| 271 | traced << tp.self 272 | end 273 | 274 | 2.times do 275 | class C; end 276 | module M; end 277 | end 278 | 279 | assert_equal [C, M, C, M], traced 280 | end 281 | 282 | # Computing hash codes is costly and we want the tracer to be as efficient as 283 | # possible. The TP callback doesn't short-circuit anonymous classes/modules 284 | # because Class.new/Module.new do not trigger the :class event. We leverage 285 | # this property to save a nil? call. 286 | # 287 | # However, if that changes in future versions of Ruby, this test would tell us 288 | # and we could revisit the callback implementation. 289 | test "trace points on the :class events don't get called on Class.new and Module.new" do 290 | on_teardown { @tracer.disable } 291 | 292 | $tracer_for_anonymous_class_and_modules_called = false 293 | @tracer = TracePoint.trace(:class) { $tracer_for_anonymous_class_and_modules_called = true } 294 | 295 | Class.new 296 | Module.new 297 | 298 | assert !$tracer_for_anonymous_class_and_modules_called 299 | end 300 | 301 | # If the user issues a require call with a Pathname object for a path that is 302 | # autoloadable, we are still able to intercept it because $LOADED_FEATURES 303 | # stores it as a string and loader_for is able to find its loader. During 304 | # unloading, we find and delete strings in $LOADED_FEATURES too. 305 | # 306 | # This is not a hard requirement, we could work around it if $LOADED_FEATURES 307 | # stored pathnames. But the code is simpler if this property holds. 308 | test "required pathnames end up as strings in $LOADED_FEATURES" do 309 | on_teardown do 310 | remove_const :X 311 | $LOADED_FEATURES.pop 312 | end 313 | 314 | files = [["x.rb", "X = 1"]] 315 | with_files(files) do 316 | with_load_path(".") do 317 | assert_equal true, require(Pathname.new("x")) 318 | assert_equal 1, X 319 | assert_equal File.expand_path("x.rb"), $LOADED_FEATURES.last 320 | end 321 | end 322 | end 323 | 324 | # This allows Im to be thread-safe on regular file autoloads. Module 325 | # autovivification is custom, has its own test. 326 | test "autoloads and constant references are synchronized" do 327 | $ensure_M_is_autoloaded_by_the_thread = Queue.new 328 | 329 | files = [["m.rb", <<-EOS]] 330 | module M 331 | $ensure_M_is_autoloaded_by_the_thread << true 332 | sleep 0.5 333 | 334 | def self.works? 335 | true 336 | end 337 | end 338 | EOS 339 | with_setup(files) do 340 | t = Thread.new { loader::M } 341 | $ensure_M_is_autoloaded_by_the_thread.pop() 342 | assert loader::M.works? 343 | t.join 344 | end 345 | end 346 | end 347 | --------------------------------------------------------------------------------