zeitwerk 2.2.0 → 2.8.2

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,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kernel
4
+ module_function
5
+
6
+ # Zeitwerk's main idea is to define autoloads for project constants, and then
7
+ # intercept them when triggered in this thin `Kernel#require` wrapper.
8
+ #
9
+ # That allows us to complete the circle, invoke callbacks, autovivify modules,
10
+ # define autoloads for just autoloaded namespaces, update internal state, etc.
11
+ #
12
+ # On the other hand, if you publish a new version of a gem that is now managed
13
+ # by Zeitwerk, client code can reference directly your classes and modules and
14
+ # should not require anything. But if someone has legacy require calls around,
15
+ # they will work as expected, and in a compatible way. This feature is by now
16
+ # EXPERIMENTAL and UNDOCUMENTED.
17
+ alias_method :zeitwerk_original_require, :require
18
+ class << self
19
+ alias_method :zeitwerk_original_require, :require
20
+ end
21
+
22
+ #: (String) -> bool
23
+ def require(path)
24
+ if loader = Zeitwerk::Registry.autoloads.registered?(path)
25
+ if path.end_with?('.rb')
26
+ required = zeitwerk_original_require(path)
27
+ loader.__on_file_autoloaded(path) if required
28
+ required
29
+ else
30
+ loader.__on_dir_autoloaded(path)
31
+ true
32
+ end
33
+ else
34
+ required = zeitwerk_original_require(path)
35
+ if required
36
+ abspath = $LOADED_FEATURES.last
37
+ if loader = Zeitwerk::Registry.autoloads.registered?(abspath)
38
+ loader.__on_file_autoloaded(abspath)
39
+ end
40
+ end
41
+ required
42
+ end
43
+ end
44
+
45
+ # By now, I have seen no way so far to decorate require_relative.
46
+ #
47
+ # For starters, at least in CRuby, require_relative does not delegate to
48
+ # require. Both require and require_relative delegate the bulk of their work
49
+ # to an internal C function called rb_require_safe. So, our require wrapper is
50
+ # not executed.
51
+ #
52
+ # On the other hand, we cannot use the aliasing technique above because
53
+ # require_relative receives a path relative to the directory of the file in
54
+ # which the call is performed. If a wrapper here invoked the original method,
55
+ # Ruby would resolve the relative path taking lib/zeitwerk as base directory.
56
+ #
57
+ # A workaround could be to extract the base directory from caller_locations,
58
+ # but what if someone else decorated require_relative before us? You can't
59
+ # really know with certainty where's the original call site in the stack.
60
+ #
61
+ # However, the main use case for require_relative is to load files from your
62
+ # own project. Projects managed by Zeitwerk don't do this for files managed by
63
+ # Zeitwerk, precisely.
64
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zeitwerk::ConstAdded # :nodoc:
4
+ #: (Symbol) -> void
5
+ def const_added(cname)
6
+ if loader = Zeitwerk::Registry.explicit_namespaces.loader_for(self, cname)
7
+ namespace = const_get(cname, false)
8
+ cref = Zeitwerk::Cref.new(self, cname)
9
+
10
+ unless namespace.is_a?(Module)
11
+ raise Zeitwerk::Error, "#{cref} is expected to be a namespace, should be a class or module (got #{namespace.class})"
12
+ end
13
+
14
+ loader.__on_namespace_loaded(cref, namespace)
15
+ end
16
+ super
17
+ end
18
+
19
+ Module.prepend(self)
20
+ end
@@ -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
@@ -0,0 +1,76 @@
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
+
70
+ #: () -> String?
71
+ def location
72
+ if (location = @mod.const_source_location(@cname)) && !location.empty?
73
+ location.join(':')
74
+ end
75
+ end
76
+ end
@@ -1,10 +1,34 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Zeitwerk
2
4
  class Error < StandardError
3
5
  end
4
6
 
5
7
  class ReloadingDisabledError < Error
8
+ #: () -> void
9
+ def initialize
10
+ super("can't reload, please call loader.enable_reloading before setup")
11
+ end
6
12
  end
7
13
 
8
14
  class NameError < ::NameError
9
15
  end
16
+
17
+ class SetupRequired < Error
18
+ #: () -> void
19
+ def initialize
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
32
+ end
33
+ end
10
34
  end
@@ -2,18 +2,16 @@
2
2
 
3
3
  module Zeitwerk
4
4
  class GemInflector < Inflector
5
- # @param root_file [String]
5
+ #: (String) -> void
6
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")
7
+ namespace = File.basename(root_file, '.rb')
8
+ root_dir = File.dirname(root_file)
9
+ @version_file = File.join(root_dir, namespace, 'version.rb')
10
10
  end
11
11
 
12
- # @param basename [String]
13
- # @param abspath [String]
14
- # @return [String]
12
+ #: (String, String) -> String
15
13
  def camelize(basename, abspath)
16
- abspath == @version_file ? "VERSION" : super
14
+ abspath == @version_file ? 'VERSION' : super
17
15
  end
18
16
  end
19
17
  end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zeitwerk
4
+ # @private
5
+ class GemLoader < Loader
6
+ include RealModName
7
+
8
+ # Users should not create instances directly, the public interface is
9
+ # `Zeitwerk::Loader.for_gem`.
10
+ private_class_method :new
11
+
12
+ # @private
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)
16
+ end
17
+
18
+ #: (String, namespace: Module, warn_on_extra_files: boolish) -> void
19
+ def initialize(root_file, namespace:, warn_on_extra_files:)
20
+ super()
21
+
22
+ @tag = File.basename(root_file, '.rb')
23
+ @tag = real_mod_name(namespace) + '-' + @tag unless namespace.equal?(Object)
24
+
25
+ @inflector = GemInflector.new(root_file)
26
+ @root_file = File.expand_path(root_file)
27
+ @root_dir = File.dirname(root_file)
28
+ @warn_on_extra_files = warn_on_extra_files
29
+
30
+ push_dir(@root_dir, namespace: namespace)
31
+ end
32
+
33
+ #: () -> void
34
+ def setup
35
+ warn_on_extra_files if @warn_on_extra_files
36
+ super
37
+ end
38
+
39
+ private
40
+
41
+ #: () -> void
42
+ def warn_on_extra_files
43
+ expected_namespace_dir = @root_file.delete_suffix('.rb')
44
+
45
+ @fs.ls(@root_dir) do |basename, abspath, ftype|
46
+ next if abspath == @root_file
47
+ next if abspath == expected_namespace_dir
48
+
49
+ basename_without_ext = basename.delete_suffix('.rb')
50
+ cname = cname_for(basename_without_ext, abspath)
51
+
52
+ warn(<<~EOS)
53
+ WARNING: Zeitwerk defines the constant #{cname} after the #{ftype}
54
+
55
+ #{abspath}
56
+
57
+ To prevent that, please configure the loader to ignore it:
58
+
59
+ loader.ignore("\#{__dir__}/#{basename}")
60
+
61
+ Otherwise, there is a flag to silence this warning:
62
+
63
+ Zeitwerk::Loader.for_gem(warn_on_extra_files: false)
64
+ EOS
65
+ end
66
+ end
67
+ end
68
+ end
@@ -5,33 +5,30 @@ 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
- # @param basename [String]
15
- # @param _abspath [String]
16
- # @return [String]
14
+ #: (String, String) -> String
17
15
  def camelize(basename, _abspath)
18
- overrides[basename] || basename.split('_').map!(&:capitalize).join
16
+ overrides[basename] || basename.split('_').each(&:capitalize!).join
19
17
  end
20
18
 
21
19
  # Configures hard-coded inflections:
22
20
  #
23
21
  # inflector = Zeitwerk::Inflector.new
24
22
  # inflector.inflect(
25
- # "html_parser" => "HTMLParser",
26
- # "mysql_adapter" => "MySQLAdapter"
23
+ # 'html_parser' => 'HTMLParser',
24
+ # 'mysql_adapter' => 'MySQLAdapter'
27
25
  # )
28
26
  #
29
- # inflector.camelize("html_parser", abspath) # => "HTMLParser"
30
- # inflector.camelize("mysql_adapter", abspath) # => "MySQLAdapter"
31
- # inflector.camelize("users_controller", abspath) # => "PostsController"
27
+ # inflector.camelize('html_parser', abspath) # => 'HTMLParser'
28
+ # inflector.camelize('mysql_adapter', abspath) # => 'MySQLAdapter'
29
+ # inflector.camelize('users_controller', abspath) # => 'UsersController'
32
30
  #
33
- # @param inflections [{String => String}]
34
- # @return [void]
31
+ #: (Hash[String, String]) -> void
35
32
  def inflect(inflections)
36
33
  overrides.merge!(inflections)
37
34
  end
@@ -41,7 +38,7 @@ module Zeitwerk
41
38
  # Hard-coded basename to constant name user maps that override the default
42
39
  # inflection logic.
43
40
  #
44
- # @return [{String => String}]
41
+ #: () -> Hash[String, String]
45
42
  def overrides
46
43
  @overrides ||= {}
47
44
  end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This is a private module.
4
+ module Zeitwerk::Internal
5
+ #: (Symbol) -> void
6
+ def internal(method_name)
7
+ private method_name
8
+
9
+ mangled = "__#{method_name}"
10
+ alias_method mangled, method_name
11
+ public mangled
12
+ end
13
+ end
@@ -1,32 +1,45 @@
1
- module Zeitwerk::Loader::Callbacks
2
- include Zeitwerk::RealModName
1
+ # frozen_string_literal: true
2
+
3
+ module Zeitwerk::Loader::Callbacks # :nodoc: all
4
+ extend Zeitwerk::Internal
3
5
 
4
6
  # Invoked from our decorated Kernel#require when a managed file is autoloaded.
5
7
  #
6
- # @private
7
- # @param file [String]
8
- # @return [void]
9
- def on_file_autoloaded(file)
8
+ #: (String) -> void ! Zeitwerk::NameError
9
+ internal def on_file_autoloaded(file)
10
10
  cref = autoloads.delete(file)
11
- to_unload[cpath(*cref)] = [file, cref] if reloading_enabled?
12
- Zeitwerk::Registry.unregister_autoload(file)
13
11
 
14
- if logger && cdef?(*cref)
15
- log("constant #{cpath(*cref)} loaded from file #{file}")
16
- elsif !cdef?(*cref)
17
- raise NameError, "expected file #{file} to define constant #{cpath(*cref)}, but didn't"
12
+ Zeitwerk::Registry.autoloads.unregister(file)
13
+
14
+ if cref.defined?
15
+ log { "constant #{cref} loaded from file #{file}" }
16
+ to_unload[file] = cref if reloading_enabled?
17
+ run_on_load_callbacks(cref.path, cref.get, file) unless on_load_callbacks.empty?
18
+ else
19
+ msg = "expected file #{file} to define constant #{cref}, but didn't"
20
+ log { msg }
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)
18
32
  end
19
33
  end
20
34
 
21
35
  # Invoked from our decorated Kernel#require when a managed directory is
22
36
  # autoloaded.
23
37
  #
24
- # @private
25
- # @param dir [String]
26
- # @return [void]
27
- def on_dir_autoloaded(dir)
28
- # Module#autoload does not serialize concurrent requires, and we handle
29
- # 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.
30
43
  #
31
44
  # Multi-threading would introduce a race condition here in which thread t1
32
45
  # autovivifies the module, and while autoloads for its children are being
@@ -35,13 +48,13 @@ module Zeitwerk::Loader::Callbacks
35
48
  # Without the mutex and subsequent delete call, t2 would reset the module.
36
49
  # That not only would reassign the constant (undesirable per se) but, worse,
37
50
  # the module object created by t2 wouldn't have any of the autoloads for its
38
- # children, since t1 would have correctly deleted its lazy_subdirs entry.
39
- mutex2.synchronize do
51
+ # children, since t1 would have correctly deleted its namespace_dirs entry.
52
+ dirs_autoload_monitor.synchronize do
40
53
  if cref = autoloads.delete(dir)
41
- autovivified_module = cref[0].const_set(cref[1], Module.new)
42
- log("module #{autovivified_module.name} autovivified from directory #{dir}") if logger
54
+ implicit_namespace = cref.set(Module.new)
55
+ log { "module #{cref} autovivified from directory #{dir}" }
43
56
 
44
- to_unload[autovivified_module.name] = [dir, cref] if reloading_enabled?
57
+ to_unload[dir] = cref if reloading_enabled?
45
58
 
46
59
  # We don't unregister `dir` in the registry because concurrent threads
47
60
  # wouldn't find a loader associated to it in Kernel#require and would
@@ -49,23 +62,35 @@ module Zeitwerk::Loader::Callbacks
49
62
  # these to be able to unregister later if eager loading.
50
63
  autoloaded_dirs << dir
51
64
 
52
- on_namespace_loaded(autovivified_module)
65
+ on_namespace_loaded(cref, implicit_namespace)
66
+
67
+ run_on_load_callbacks(cref.path, implicit_namespace, dir) unless on_load_callbacks.empty?
53
68
  end
54
69
  end
55
70
  end
56
71
 
57
- # Invoked when a class or module is created or reopened, either from the
58
- # tracer or from module autovivification. If the namespace has matching
59
- # 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.
60
75
  #
61
- # @private
62
- # @param namespace [Module]
63
- # @return [void]
64
- def on_namespace_loaded(namespace)
65
- if subdirs = lazy_subdirs.delete(real_mod_name(namespace))
66
- subdirs.each do |subdir|
67
- set_autoloads_in_dir(subdir, namespace)
76
+ #: (Zeitwerk::Cref, Module) -> void
77
+ internal def on_namespace_loaded(cref, namespace)
78
+ if dirs = namespace_dirs.delete(cref)
79
+ dirs.each do |dir|
80
+ define_autoloads_for_dir(dir, namespace, external: false)
68
81
  end
69
82
  end
70
83
  end
84
+
85
+ private
86
+
87
+ #: (String, top, String) -> void
88
+ def run_on_load_callbacks(cpath, value, abspath)
89
+ # Order matters. If present, run the most specific one.
90
+ callbacks = reloading_enabled? ? on_load_callbacks[cpath] : on_load_callbacks.delete(cpath)
91
+ callbacks&.each { |c| c.call(value, abspath) }
92
+
93
+ callbacks = on_load_callbacks[:ANY]
94
+ callbacks&.each { |c| c.call(cpath, value, abspath) }
95
+ end
71
96
  end