zeitwerk 2.6.7 → 2.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Description of the structure
4
+ # ----------------------------
5
+ #
6
+ # This class emulates a hash table whose keys are of type Zeitwerk::Cref.
7
+ #
8
+ # It is a synchronized 2-level hash.
9
+ #
10
+ # The keys of the top one, stored in `@map`, are class and module objects, but
11
+ # their hash code is forced to be their object IDs because class and module
12
+ # objects may not be hashable (https://github.com/fxn/zeitwerk/issues/188).
13
+ #
14
+ # Then, each one of them stores a hash table with their constants and values.
15
+ # Constants are stored as symbols.
16
+ #
17
+ # For example, if we store values 0, 1, and 2 for the crefs that would
18
+ # correspond to `M::X`, `M::Y`, and `N::Z`, the map will look like this:
19
+ #
20
+ # { M => { X: 0, :Y => 1 }, N => { Z: 2 } }
21
+ #
22
+ # This structure is internal, so only the needed interface is implemented.
23
+ #
24
+ # Alternative approaches
25
+ # -----------------------
26
+ #
27
+ # 1. We could also use a 1-level hash whose keys are constant paths. In the
28
+ # example above it would be:
29
+ #
30
+ # { "M::X" => 0, "M::Y" => 1, "N::Z" => 2 }
31
+ #
32
+ # The gem used this approach for several years.
33
+ #
34
+ # 2. Write a custom `hash`/`eql?` in Zeitwerk::Cref. Hash code would be
35
+ #
36
+ # real_mod_hash(@mod) ^ @cname.hash
37
+ #
38
+ # where `real_mod_hash(@mod)` would actually be a call to the real `hash`
39
+ # method in Module. Like what we do for module names to bypass overrides.
40
+ #
41
+ # 3. Similar to 2, but use
42
+ #
43
+ # @mod.object_id ^ @cname.object_id
44
+ #
45
+ # as hash code instead.
46
+ #
47
+ # Benchamrks
48
+ # ----------
49
+ #
50
+ # Writing:
51
+ #
52
+ # map - baseline
53
+ # (3) - 1.74x slower
54
+ # (2) - 2.91x slower
55
+ # (1) - 3.87x slower
56
+ #
57
+ # Reading:
58
+ #
59
+ # map - baseline
60
+ # (3) - 1.99x slower
61
+ # (2) - 2.80x slower
62
+ # (1) - 3.48x slower
63
+ #
64
+ # Extra ball
65
+ # ----------
66
+ #
67
+ # In addition to that, the map is synchronized and provides `delete_mod_cname`,
68
+ # which is ad-hoc for the hot path in `const_added`, we do not need to create
69
+ # unnecessary cref objects for constants we do not manage (but we do not know in
70
+ # advance there).
71
+
72
+ #: [Value]
73
+ class Zeitwerk::Cref::Map # :nodoc: all
74
+ #: () -> void
75
+ def initialize
76
+ @map = {}
77
+ @map.compare_by_identity
78
+ @mutex = Mutex.new
79
+ end
80
+
81
+ #: (Zeitwerk::Cref, Value) -> Value
82
+ def []=(cref, value)
83
+ @mutex.synchronize do
84
+ cnames = (@map[cref.mod] ||= {})
85
+ cnames[cref.cname] = value
86
+ end
87
+ end
88
+
89
+ #: (Zeitwerk::Cref) -> Value?
90
+ def [](cref)
91
+ @mutex.synchronize do
92
+ @map[cref.mod]&.[](cref.cname)
93
+ end
94
+ end
95
+
96
+ #: (Zeitwerk::Cref, { () -> Value }) -> Value
97
+ def get_or_set(cref, &block)
98
+ @mutex.synchronize do
99
+ cnames = (@map[cref.mod] ||= {})
100
+ cnames.fetch(cref.cname) { cnames[cref.cname] = block.call }
101
+ end
102
+ end
103
+
104
+ #: (Zeitwerk::Cref) -> Value?
105
+ def delete(cref)
106
+ delete_mod_cname(cref.mod, cref.cname)
107
+ end
108
+
109
+ # Ad-hoc for loader_for, called from const_added. That is a hot path, I prefer
110
+ # to not create a cref in every call, since that is global.
111
+ #
112
+ #: (Module, Symbol) -> Value?
113
+ def delete_mod_cname(mod, cname)
114
+ @mutex.synchronize do
115
+ if cnames = @map[mod]
116
+ value = cnames.delete(cname)
117
+ @map.delete(mod) if cnames.empty?
118
+ value
119
+ end
120
+ end
121
+ end
122
+
123
+ #: (Value) -> void
124
+ def delete_by_value(value)
125
+ @mutex.synchronize do
126
+ @map.delete_if do |mod, cnames|
127
+ cnames.delete_if { _2 == value }
128
+ cnames.empty?
129
+ end
130
+ end
131
+ end
132
+
133
+ # Order of yielded crefs is undefined.
134
+ #
135
+ #: () { (Zeitwerk::Cref) -> void } -> void
136
+ def each_key
137
+ @mutex.synchronize do
138
+ @map.each do |mod, cnames|
139
+ cnames.each_key do |cname|
140
+ yield Zeitwerk::Cref.new(mod, cname)
141
+ end
142
+ end
143
+ end
144
+ end
145
+
146
+ #: () -> void
147
+ def clear
148
+ @mutex.synchronize do
149
+ @map.clear
150
+ end
151
+ end
152
+
153
+ #: () -> bool
154
+ def empty? # for tests
155
+ @mutex.synchronize do
156
+ @map.empty?
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This private class encapsulates pairs (mod, cname).
4
+ #
5
+ # Objects represent the constant `cname` in the class or module object `mod`,
6
+ # and have API to manage them. Examples:
7
+ #
8
+ # cref.path
9
+ # cref.set(value)
10
+ # cref.get
11
+ #
12
+ # The constant may or may not exist in `mod`.
13
+ class Zeitwerk::Cref
14
+ require_relative "cref/map"
15
+
16
+ include Zeitwerk::RealModName
17
+
18
+ #: Module
19
+ attr_reader :mod
20
+
21
+ #: Symbol
22
+ attr_reader :cname
23
+
24
+ # The type of the first argument is Module because Class < Module, class
25
+ # objects are also valid.
26
+ #
27
+ #: (Module, Symbol) -> void
28
+ def initialize(mod, cname)
29
+ @mod = mod
30
+ @cname = cname
31
+ @path = nil
32
+ end
33
+
34
+ #: () -> String
35
+ def path
36
+ @path ||= Object == @mod ? @cname.name : "#{real_mod_name(@mod)}::#{@cname.name}".freeze
37
+ end
38
+ alias to_s path
39
+
40
+ #: () -> String?
41
+ def autoload?
42
+ @mod.autoload?(@cname, false)
43
+ end
44
+
45
+ #: (String) -> nil
46
+ def autoload(abspath)
47
+ @mod.autoload(@cname, abspath)
48
+ end
49
+
50
+ #: () -> bool
51
+ def defined?
52
+ @mod.const_defined?(@cname, false)
53
+ end
54
+
55
+ #: (top) -> top
56
+ def set(value)
57
+ @mod.const_set(@cname, value)
58
+ end
59
+
60
+ #: () -> top ! NameError
61
+ def get
62
+ @mod.const_get(@cname, false)
63
+ end
64
+
65
+ #: () -> void ! NameError
66
+ def remove
67
+ @mod.__send__(:remove_const, @cname)
68
+ end
69
+ end
@@ -5,6 +5,7 @@ module Zeitwerk
5
5
  end
6
6
 
7
7
  class ReloadingDisabledError < Error
8
+ #: () -> void
8
9
  def initialize
9
10
  super("can't reload, please call loader.enable_reloading before setup")
10
11
  end
@@ -14,6 +15,7 @@ module Zeitwerk
14
15
  end
15
16
 
16
17
  class SetupRequired < Error
18
+ #: () -> void
17
19
  def initialize
18
20
  super("please, finish your configuration and call Zeitwerk::Loader#setup once all is ready")
19
21
  end
@@ -2,14 +2,14 @@
2
2
 
3
3
  module Zeitwerk
4
4
  class GemInflector < Inflector
5
- # @sig (String) -> void
5
+ #: (String) -> void
6
6
  def initialize(root_file)
7
7
  namespace = File.basename(root_file, ".rb")
8
- lib_dir = File.dirname(root_file)
9
- @version_file = File.join(lib_dir, namespace, "version.rb")
8
+ root_dir = File.dirname(root_file)
9
+ @version_file = File.join(root_dir, namespace, "version.rb")
10
10
  end
11
11
 
12
- # @sig (String, String) -> String
12
+ #: (String, String) -> String
13
13
  def camelize(basename, abspath)
14
14
  abspath == @version_file ? "VERSION" : super
15
15
  end
@@ -3,30 +3,34 @@
3
3
  module Zeitwerk
4
4
  # @private
5
5
  class GemLoader < Loader
6
+ include RealModName
7
+
6
8
  # Users should not create instances directly, the public interface is
7
9
  # `Zeitwerk::Loader.for_gem`.
8
10
  private_class_method :new
9
11
 
10
12
  # @private
11
- # @sig (String, bool) -> Zeitwerk::GemLoader
12
- def self._new(root_file, warn_on_extra_files:)
13
- new(root_file, warn_on_extra_files: warn_on_extra_files)
13
+ #: (String, namespace: Module, warn_on_extra_files: boolish) -> Zeitwerk::GemLoader
14
+ def self.__new(root_file, namespace:, warn_on_extra_files:)
15
+ new(root_file, namespace: namespace, warn_on_extra_files: warn_on_extra_files)
14
16
  end
15
17
 
16
- # @sig (String, bool) -> void
17
- def initialize(root_file, warn_on_extra_files:)
18
+ #: (String, namespace: Module, warn_on_extra_files: boolish) -> void
19
+ def initialize(root_file, namespace:, warn_on_extra_files:)
18
20
  super()
19
21
 
20
- @tag = File.basename(root_file, ".rb")
22
+ @tag = File.basename(root_file, ".rb")
23
+ @tag = real_mod_name(namespace) + "-" + @tag unless namespace.equal?(Object)
24
+
21
25
  @inflector = GemInflector.new(root_file)
22
26
  @root_file = File.expand_path(root_file)
23
- @lib = File.dirname(root_file)
27
+ @root_dir = File.dirname(root_file)
24
28
  @warn_on_extra_files = warn_on_extra_files
25
29
 
26
- push_dir(@lib)
30
+ push_dir(@root_dir, namespace: namespace)
27
31
  end
28
32
 
29
- # @sig () -> void
33
+ #: () -> void
30
34
  def setup
31
35
  warn_on_extra_files if @warn_on_extra_files
32
36
  super
@@ -34,17 +38,16 @@ module Zeitwerk
34
38
 
35
39
  private
36
40
 
37
- # @sig () -> void
41
+ #: () -> void
38
42
  def warn_on_extra_files
39
43
  expected_namespace_dir = @root_file.delete_suffix(".rb")
40
44
 
41
- ls(@lib) do |basename, abspath|
45
+ ls(@root_dir) do |basename, abspath, ftype|
42
46
  next if abspath == @root_file
43
47
  next if abspath == expected_namespace_dir
44
48
 
45
49
  basename_without_ext = basename.delete_suffix(".rb")
46
50
  cname = inflector.camelize(basename_without_ext, abspath).to_sym
47
- ftype = dir?(abspath) ? "directory" : "file"
48
51
 
49
52
  warn(<<~EOS)
50
53
  WARNING: Zeitwerk defines the constant #{cname} after the #{ftype}
@@ -11,7 +11,7 @@ module Zeitwerk
11
11
  #
12
12
  # Takes into account hard-coded mappings configured with `inflect`.
13
13
  #
14
- # @sig (String, String) -> String
14
+ #: (String, String) -> String
15
15
  def camelize(basename, _abspath)
16
16
  overrides[basename] || basename.split('_').each(&:capitalize!).join
17
17
  end
@@ -28,7 +28,7 @@ module Zeitwerk
28
28
  # inflector.camelize("mysql_adapter", abspath) # => "MySQLAdapter"
29
29
  # inflector.camelize("users_controller", abspath) # => "UsersController"
30
30
  #
31
- # @sig (Hash[String, String]) -> void
31
+ #: (Hash[String, String]) -> void
32
32
  def inflect(inflections)
33
33
  overrides.merge!(inflections)
34
34
  end
@@ -38,7 +38,7 @@ module Zeitwerk
38
38
  # Hard-coded basename to constant name user maps that override the default
39
39
  # inflection logic.
40
40
  #
41
- # @sig () -> Hash[String, String]
41
+ #: () -> Hash[String, String]
42
42
  def overrides
43
43
  @overrides ||= {}
44
44
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  # This is a private module.
4
4
  module Zeitwerk::Internal
5
+ #: (Symbol) -> void
5
6
  def internal(method_name)
6
7
  private method_name
7
8
 
@@ -1,39 +1,45 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Zeitwerk::Loader::Callbacks
4
- include Zeitwerk::RealModName
3
+ module Zeitwerk::Loader::Callbacks # :nodoc: all
4
+ extend Zeitwerk::Internal
5
5
 
6
6
  # Invoked from our decorated Kernel#require when a managed file is autoloaded.
7
7
  #
8
- # @private
9
- # @sig (String) -> void
10
- def on_file_autoloaded(file)
11
- cref = autoloads.delete(file)
12
- cpath = cpath(*cref)
13
-
14
- Zeitwerk::Registry.unregister_autoload(file)
15
-
16
- if cdef?(*cref)
17
- log("constant #{cpath} loaded from file #{file}") if logger
18
- to_unload[cpath] = [file, cref] if reloading_enabled?
19
- run_on_load_callbacks(cpath, cget(*cref), file) unless on_load_callbacks.empty?
8
+ #: (String) -> void ! Zeitwerk::NameError
9
+ internal def on_file_autoloaded(file)
10
+ cref = autoloads.delete(file)
11
+
12
+ Zeitwerk::Registry.autoloads.unregister(file)
13
+
14
+ if cref.defined?
15
+ log("constant #{cref} loaded from file #{file}") if logger
16
+ to_unload[file] = cref if reloading_enabled?
17
+ run_on_load_callbacks(cref.path, cref.get, file) unless on_load_callbacks.empty?
20
18
  else
21
- msg = "expected file #{file} to define constant #{cpath}, but didn't"
19
+ msg = "expected file #{file} to define constant #{cref}, but didn't"
22
20
  log(msg) if logger
23
- crem(*cref)
24
- to_unload[cpath] = [file, cref] if reloading_enabled?
25
- raise Zeitwerk::NameError.new(msg, cref.last)
21
+
22
+ # Ruby still keeps the autoload defined, but we remove it because the
23
+ # contract in Zeitwerk is more strict.
24
+ cref.remove
25
+
26
+ # Since the expected constant was not defined, there is nothing to unload.
27
+ # However, if the exception is rescued and reloading is enabled, we still
28
+ # need to deleted the file from $LOADED_FEATURES.
29
+ to_unload[file] = cref if reloading_enabled?
30
+
31
+ raise Zeitwerk::NameError.new(msg, cref.cname)
26
32
  end
27
33
  end
28
34
 
29
35
  # Invoked from our decorated Kernel#require when a managed directory is
30
36
  # autoloaded.
31
37
  #
32
- # @private
33
- # @sig (String) -> void
34
- def on_dir_autoloaded(dir)
35
- # Module#autoload does not serialize concurrent requires, and we handle
36
- # directories ourselves, so the callback needs to account for concurrency.
38
+ #: (String) -> void
39
+ internal def on_dir_autoloaded(dir)
40
+ # Module#autoload does not serialize concurrent requires in CRuby < 3.2, and
41
+ # we handle directories ourselves without going through Kernel#require, so
42
+ # the callback needs to account for concurrency.
37
43
  #
38
44
  # Multi-threading would introduce a race condition here in which thread t1
39
45
  # autovivifies the module, and while autoloads for its children are being
@@ -43,13 +49,12 @@ module Zeitwerk::Loader::Callbacks
43
49
  # That not only would reassign the constant (undesirable per se) but, worse,
44
50
  # the module object created by t2 wouldn't have any of the autoloads for its
45
51
  # children, since t1 would have correctly deleted its namespace_dirs entry.
46
- mutex2.synchronize do
52
+ dirs_autoload_monitor.synchronize do
47
53
  if cref = autoloads.delete(dir)
48
- autovivified_module = cref[0].const_set(cref[1], Module.new)
49
- cpath = autovivified_module.name
50
- log("module #{cpath} autovivified from directory #{dir}") if logger
54
+ implicit_namespace = cref.set(Module.new)
55
+ log("module #{cref} autovivified from directory #{dir}") if logger
51
56
 
52
- to_unload[cpath] = [dir, cref] if reloading_enabled?
57
+ to_unload[dir] = cref if reloading_enabled?
53
58
 
54
59
  # We don't unregister `dir` in the registry because concurrent threads
55
60
  # wouldn't find a loader associated to it in Kernel#require and would
@@ -57,30 +62,29 @@ module Zeitwerk::Loader::Callbacks
57
62
  # these to be able to unregister later if eager loading.
58
63
  autoloaded_dirs << dir
59
64
 
60
- on_namespace_loaded(autovivified_module)
65
+ on_namespace_loaded(cref, implicit_namespace)
61
66
 
62
- run_on_load_callbacks(cpath, autovivified_module, dir) unless on_load_callbacks.empty?
67
+ run_on_load_callbacks(cref.path, implicit_namespace, dir) unless on_load_callbacks.empty?
63
68
  end
64
69
  end
65
70
  end
66
71
 
67
- # Invoked when a class or module is created or reopened, either from the
68
- # tracer or from module autovivification. If the namespace has matching
69
- # subdirectories, we descend into them now.
72
+ # Invoked when a namespace is created, either from const_added or from module
73
+ # autovivification. If the namespace has matching subdirectories, we descend
74
+ # into them now.
70
75
  #
71
- # @private
72
- # @sig (Module) -> void
73
- def on_namespace_loaded(namespace)
74
- if dirs = namespace_dirs.delete(real_mod_name(namespace))
76
+ #: (Zeitwerk::Cref, Module) -> void
77
+ internal def on_namespace_loaded(cref, namespace)
78
+ if dirs = namespace_dirs.delete(cref)
75
79
  dirs.each do |dir|
76
- set_autoloads_in_dir(dir, namespace)
80
+ define_autoloads_for_dir(dir, namespace)
77
81
  end
78
82
  end
79
83
  end
80
84
 
81
85
  private
82
86
 
83
- # @sig (String, Object) -> void
87
+ #: (String, top, String) -> void
84
88
  def run_on_load_callbacks(cpath, value, abspath)
85
89
  # Order matters. If present, run the most specific one.
86
90
  callbacks = reloading_enabled? ? on_load_callbacks[cpath] : on_load_callbacks.delete(cpath)