zeitwerk 2.6.18 → 2.8.1

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
+ # Benchmarks
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
data/lib/zeitwerk/cref.rb CHANGED
@@ -2,98 +2,75 @@
2
2
 
3
3
  # This private class encapsulates pairs (mod, cname).
4
4
  #
5
- # Objects represent the constant cname in the class or module object mod, and
6
- # have API to manage them that encapsulates the constants API. Examples:
5
+ # Objects represent the constant `cname` in the class or module object `mod`,
6
+ # and have API to manage them. Examples:
7
7
  #
8
8
  # cref.path
9
9
  # cref.set(value)
10
10
  # cref.get
11
11
  #
12
- # The constant may or may not exist in mod.
12
+ # The constant may or may not exist in `mod`.
13
13
  class Zeitwerk::Cref
14
+ require_relative 'cref/map'
15
+
14
16
  include Zeitwerk::RealModName
15
17
 
16
- # @sig Symbol
18
+ #: Module
19
+ attr_reader :mod
20
+
21
+ #: Symbol
17
22
  attr_reader :cname
18
23
 
19
24
  # The type of the first argument is Module because Class < Module, class
20
25
  # objects are also valid.
21
26
  #
22
- # @sig (Module, Symbol) -> void
27
+ #: (Module, Symbol) -> void
23
28
  def initialize(mod, cname)
24
29
  @mod = mod
25
30
  @cname = cname
26
31
  @path = nil
27
32
  end
28
33
 
29
- if Symbol.method_defined?(:name)
30
- # Symbol#name was introduced in Ruby 3.0. It returns always the same
31
- # frozen object, so we may save a few string allocations.
32
- #
33
- # @sig () -> String
34
- def path
35
- @path ||= Object.equal?(@mod) ? @cname.name : "#{real_mod_name(@mod)}::#{@cname.name}"
36
- end
37
- else
38
- # @sig () -> String
39
- def path
40
- @path ||= Object.equal?(@mod) ? @cname.to_s : "#{real_mod_name(@mod)}::#{@cname}"
41
- end
34
+ #: () -> String
35
+ def path
36
+ @path ||= Object == @mod ? @cname.name : "#{real_mod_name(@mod)}::#{@cname.name}".freeze
42
37
  end
38
+ alias to_s path
43
39
 
44
- # The autoload? predicate takes into account the ancestor chain of the
45
- # receiver, like const_defined? and other methods in the constants API do.
46
- #
47
- # For example, given
48
- #
49
- # class A
50
- # autoload :X, "x.rb"
51
- # end
52
- #
53
- # class B < A
54
- # end
55
- #
56
- # B.autoload?(:X) returns "x.rb".
57
- #
58
- # We need a way to retrieve it ignoring ancestors.
59
- #
60
- # @sig () -> String?
61
- if method(:autoload?).arity == 1
62
- # @sig () -> String?
63
- def autoload?
64
- @mod.autoload?(@cname) if self.defined?
65
- end
66
- else
67
- # @sig () -> String?
68
- def autoload?
69
- @mod.autoload?(@cname, false)
70
- end
40
+ #: () -> String?
41
+ def autoload?
42
+ @mod.autoload?(@cname, false)
71
43
  end
72
44
 
73
- # @sig (String) -> bool
45
+ #: (String) -> nil
74
46
  def autoload(abspath)
75
47
  @mod.autoload(@cname, abspath)
76
48
  end
77
49
 
78
- # @sig () -> bool
50
+ #: () -> bool
79
51
  def defined?
80
52
  @mod.const_defined?(@cname, false)
81
53
  end
82
54
 
83
- # @sig (Object) -> Object
55
+ #: (top) -> top
84
56
  def set(value)
85
57
  @mod.const_set(@cname, value)
86
58
  end
87
59
 
88
- # @raise [NameError]
89
- # @sig () -> Object
60
+ #: () -> top ! NameError
90
61
  def get
91
62
  @mod.const_get(@cname, false)
92
63
  end
93
64
 
94
- # @raise [NameError]
95
- # @sig () -> void
65
+ #: () -> void ! NameError
96
66
  def remove
97
67
  @mod.__send__(:remove_const, @cname)
98
68
  end
69
+
70
+ #: () -> String?
71
+ def location
72
+ if (location = @mod.const_source_location(@cname)) && !location.empty?
73
+ location.join(':')
74
+ end
75
+ end
99
76
  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,8 +15,20 @@ module Zeitwerk
14
15
  end
15
16
 
16
17
  class SetupRequired < Error
18
+ #: () -> void
17
19
  def initialize
18
- super("please, finish your configuration and call Zeitwerk::Loader#setup once all is ready")
20
+ super('please, finish your configuration and call Zeitwerk::Loader#setup once all is ready')
21
+ end
22
+ end
23
+
24
+ class ConflictingNamespaceDefinitionError < Error
25
+ #: (String, location: String?, conflicting_file: String) -> void
26
+ def initialize(cpath, location:, conflicting_file:)
27
+ if location
28
+ super("conflicting namespace definition for #{cpath}: #{conflicting_file} conflicts with #{location}")
29
+ else
30
+ super("conflicting namespace definition for #{cpath}: #{conflicting_file} conflicts with an already defined namespace")
31
+ end
19
32
  end
20
33
  end
21
34
  end
@@ -2,16 +2,16 @@
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
- namespace = File.basename(root_file, ".rb")
7
+ namespace = File.basename(root_file, '.rb')
8
8
  root_dir = File.dirname(root_file)
9
- @version_file = File.join(root_dir, namespace, "version.rb")
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
- abspath == @version_file ? "VERSION" : super
14
+ abspath == @version_file ? 'VERSION' : super
15
15
  end
16
16
  end
17
17
  end
@@ -10,17 +10,17 @@ module Zeitwerk
10
10
  private_class_method :new
11
11
 
12
12
  # @private
13
- # @sig (String, bool) -> Zeitwerk::GemLoader
13
+ #: (String, namespace: Module, warn_on_extra_files: boolish) -> Zeitwerk::GemLoader
14
14
  def self.__new(root_file, namespace:, warn_on_extra_files:)
15
15
  new(root_file, namespace: namespace, warn_on_extra_files: warn_on_extra_files)
16
16
  end
17
17
 
18
- # @sig (String, bool) -> void
18
+ #: (String, namespace: Module, warn_on_extra_files: boolish) -> void
19
19
  def initialize(root_file, namespace:, warn_on_extra_files:)
20
20
  super()
21
21
 
22
- @tag = File.basename(root_file, ".rb")
23
- @tag = real_mod_name(namespace) + "-" + @tag unless namespace.equal?(Object)
22
+ @tag = File.basename(root_file, '.rb')
23
+ @tag = real_mod_name(namespace) + '-' + @tag unless namespace.equal?(Object)
24
24
 
25
25
  @inflector = GemInflector.new(root_file)
26
26
  @root_file = File.expand_path(root_file)
@@ -30,7 +30,7 @@ module Zeitwerk
30
30
  push_dir(@root_dir, namespace: namespace)
31
31
  end
32
32
 
33
- # @sig () -> void
33
+ #: () -> void
34
34
  def setup
35
35
  warn_on_extra_files if @warn_on_extra_files
36
36
  super
@@ -38,16 +38,16 @@ module Zeitwerk
38
38
 
39
39
  private
40
40
 
41
- # @sig () -> void
41
+ #: () -> void
42
42
  def warn_on_extra_files
43
- expected_namespace_dir = @root_file.delete_suffix(".rb")
43
+ expected_namespace_dir = @root_file.delete_suffix('.rb')
44
44
 
45
- ls(@root_dir) do |basename, abspath, ftype|
45
+ @fs.ls(@root_dir) do |basename, abspath, ftype|
46
46
  next if abspath == @root_file
47
47
  next if abspath == expected_namespace_dir
48
48
 
49
- basename_without_ext = basename.delete_suffix(".rb")
50
- cname = inflector.camelize(basename_without_ext, abspath).to_sym
49
+ basename_without_ext = basename.delete_suffix('.rb')
50
+ cname = cname_for(basename_without_ext, abspath)
51
51
 
52
52
  warn(<<~EOS)
53
53
  WARNING: Zeitwerk defines the constant #{cname} after the #{ftype}
@@ -5,13 +5,13 @@ module Zeitwerk
5
5
  # Very basic snake case -> camel case conversion.
6
6
  #
7
7
  # inflector = Zeitwerk::Inflector.new
8
- # inflector.camelize("post", ...) # => "Post"
9
- # inflector.camelize("users_controller", ...) # => "UsersController"
10
- # inflector.camelize("api", ...) # => "Api"
8
+ # inflector.camelize('post', ...) # => 'Post'
9
+ # inflector.camelize('users_controller', ...) # => 'UsersController'
10
+ # inflector.camelize('api', ...) # => 'Api'
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
@@ -20,15 +20,15 @@ module Zeitwerk
20
20
  #
21
21
  # inflector = Zeitwerk::Inflector.new
22
22
  # inflector.inflect(
23
- # "html_parser" => "HTMLParser",
24
- # "mysql_adapter" => "MySQLAdapter"
23
+ # 'html_parser' => 'HTMLParser',
24
+ # 'mysql_adapter' => 'MySQLAdapter'
25
25
  # )
26
26
  #
27
- # inflector.camelize("html_parser", abspath) # => "HTMLParser"
28
- # inflector.camelize("mysql_adapter", abspath) # => "MySQLAdapter"
29
- # inflector.camelize("users_controller", abspath) # => "UsersController"
27
+ # inflector.camelize('html_parser', abspath) # => 'HTMLParser'
28
+ # inflector.camelize('mysql_adapter', abspath) # => 'MySQLAdapter'
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,24 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Zeitwerk::Loader::Callbacks
4
- include Zeitwerk::RealModName
3
+ module Zeitwerk::Loader::Callbacks # :nodoc: all
5
4
  extend Zeitwerk::Internal
6
5
 
7
6
  # Invoked from our decorated Kernel#require when a managed file is autoloaded.
8
7
  #
9
- # @sig (String) -> void
8
+ #: (String) -> void ! Zeitwerk::NameError
10
9
  internal def on_file_autoloaded(file)
11
10
  cref = autoloads.delete(file)
12
11
 
13
- Zeitwerk::Registry.unregister_autoload(file)
12
+ Zeitwerk::Registry.autoloads.unregister(file)
14
13
 
15
14
  if cref.defined?
16
- log("constant #{cref.path} loaded from file #{file}") if logger
17
- to_unload[cref.path] = [file, cref] if reloading_enabled?
15
+ log { "constant #{cref} loaded from file #{file}" }
16
+ to_unload[file] = cref if reloading_enabled?
18
17
  run_on_load_callbacks(cref.path, cref.get, file) unless on_load_callbacks.empty?
19
18
  else
20
- msg = "expected file #{file} to define constant #{cref.path}, but didn't"
21
- log(msg) if logger
19
+ msg = "expected file #{file} to define constant #{cref}, but didn't"
20
+ log { msg }
22
21
 
23
22
  # Ruby still keeps the autoload defined, but we remove it because the
24
23
  # contract in Zeitwerk is more strict.
@@ -27,7 +26,7 @@ module Zeitwerk::Loader::Callbacks
27
26
  # Since the expected constant was not defined, there is nothing to unload.
28
27
  # However, if the exception is rescued and reloading is enabled, we still
29
28
  # need to deleted the file from $LOADED_FEATURES.
30
- to_unload[cref.path] = [file, cref] if reloading_enabled?
29
+ to_unload[file] = cref if reloading_enabled?
31
30
 
32
31
  raise Zeitwerk::NameError.new(msg, cref.cname)
33
32
  end
@@ -36,7 +35,7 @@ module Zeitwerk::Loader::Callbacks
36
35
  # Invoked from our decorated Kernel#require when a managed directory is
37
36
  # autoloaded.
38
37
  #
39
- # @sig (String) -> void
38
+ #: (String) -> void
40
39
  internal def on_dir_autoloaded(dir)
41
40
  # Module#autoload does not serialize concurrent requires in CRuby < 3.2, and
42
41
  # we handle directories ourselves without going through Kernel#require, so
@@ -53,10 +52,9 @@ module Zeitwerk::Loader::Callbacks
53
52
  dirs_autoload_monitor.synchronize do
54
53
  if cref = autoloads.delete(dir)
55
54
  implicit_namespace = cref.set(Module.new)
56
- cpath = implicit_namespace.name
57
- log("module #{cpath} autovivified from directory #{dir}") if logger
55
+ log { "module #{cref} autovivified from directory #{dir}" }
58
56
 
59
- to_unload[cpath] = [dir, cref] if reloading_enabled?
57
+ to_unload[dir] = cref if reloading_enabled?
60
58
 
61
59
  # We don't unregister `dir` in the registry because concurrent threads
62
60
  # wouldn't find a loader associated to it in Kernel#require and would
@@ -64,30 +62,29 @@ module Zeitwerk::Loader::Callbacks
64
62
  # these to be able to unregister later if eager loading.
65
63
  autoloaded_dirs << dir
66
64
 
67
- on_namespace_loaded(implicit_namespace)
65
+ on_namespace_loaded(cref, implicit_namespace)
68
66
 
69
- run_on_load_callbacks(cpath, implicit_namespace, dir) unless on_load_callbacks.empty?
67
+ run_on_load_callbacks(cref.path, implicit_namespace, dir) unless on_load_callbacks.empty?
70
68
  end
71
69
  end
72
70
  end
73
71
 
74
- # Invoked when a class or module is created or reopened, either from the
75
- # tracer or from module autovivification. If the namespace has matching
76
- # 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.
77
75
  #
78
- # @private
79
- # @sig (Module) -> void
80
- def on_namespace_loaded(namespace)
81
- 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)
82
79
  dirs.each do |dir|
83
- define_autoloads_for_dir(dir, namespace)
80
+ define_autoloads_for_dir(dir, namespace, external: false)
84
81
  end
85
82
  end
86
83
  end
87
84
 
88
85
  private
89
86
 
90
- # @sig (String, Object) -> void
87
+ #: (String, top, String) -> void
91
88
  def run_on_load_callbacks(cpath, value, abspath)
92
89
  # Order matters. If present, run the most specific one.
93
90
  callbacks = reloading_enabled? ? on_load_callbacks[cpath] : on_load_callbacks.delete(cpath)