lib/zeitwerk/cref/map.rb



# frozen_string_literal: true

# Description of the structure
# ----------------------------
#
# This class emulates a hash table whose keys are of type Zeitwerk::Cref.
#
# It is a synchronized 2-level hash.
#
# The keys of the top one, stored in `@map`, are class and module objects, but
# their hash code is forced to be their object IDs because class and module
# objects may not be hashable (https://github.com/fxn/zeitwerk/issues/188).
#
# Then, each one of them stores a hash table with their constants and values.
# Constants are stored as symbols.
#
# For example, if we store values 0, 1, and 2 for the crefs that would
# correspond to `M::X`, `M::Y`, and `N::Z`, the map will look like this:
#
#   { M => { X: 0, :Y => 1 }, N => { Z: 2 } }
#
# This structure is internal, so only the needed interface is implemented.
#
# Alternative approaches
# -----------------------
#
# 1. We could also use a 1-level hash whose keys are constant paths. In the
#    example above it would be:
#
#      { "M::X" => 0, "M::Y" => 1, "N::Z" => 2 }
#
#    The gem used this approach for several years.
#
#  2. Write a custom `hash`/`eql?` in Zeitwerk::Cref. Hash code would be
#
#       real_mod_hash(@mod) ^ @cname.hash
#
#     where `real_mod_hash(@mod)` would actually be a call to the real `hash`
#     method in Module. Like what we do for module names to bypass overrides.
#
#  3. Similar to 2, but use
#
#       @mod.object_id ^ @cname.object_id
#
#     as hash code instead.
#
# Benchamrks
# ----------
#
# Writing:
#
#   map - baseline
#   (3) - 1.74x  slower
#   (2) - 2.91x  slower
#   (1) - 3.87x  slower
#
# Reading:
#
#   map - baseline
#   (3) - 1.99x  slower
#   (2) - 2.80x  slower
#   (1) - 3.48x  slower
#
# Extra ball
# ----------
#
# In addition to that, the map is synchronized and provides `delete_mod_cname`,
# which is ad-hoc for the hot path in `const_added`, we do not need to create
# unnecessary cref objects for constants we do not manage (but we do not know in
# advance there).

#: [Value]
class Zeitwerk::Cref::Map # :nodoc: all
  #: () -> void
  def initialize
    @map = {}
    @map.compare_by_identity
    @mutex = Mutex.new
  end

  #: (Zeitwerk::Cref, Value) -> Value
  def []=(cref, value)
    @mutex.synchronize do
      cnames = (@map[cref.mod] ||= {})
      cnames[cref.cname] = value
    end
  end

  #: (Zeitwerk::Cref) -> Value?
  def [](cref)
    @mutex.synchronize do
      @map[cref.mod]&.[](cref.cname)
    end
  end

  #: (Zeitwerk::Cref, { () -> Value }) -> Value
  def get_or_set(cref, &block)
    @mutex.synchronize do
      cnames = (@map[cref.mod] ||= {})
      cnames.fetch(cref.cname) { cnames[cref.cname] = block.call }
    end
  end

  #: (Zeitwerk::Cref) -> Value?
  def delete(cref)
    delete_mod_cname(cref.mod, cref.cname)
  end

  # Ad-hoc for loader_for, called from const_added. That is a hot path, I prefer
  # to not create a cref in every call, since that is global.
  #
  #: (Module, Symbol) -> Value?
  def delete_mod_cname(mod, cname)
    @mutex.synchronize do
      if cnames = @map[mod]
        value = cnames.delete(cname)
        @map.delete(mod) if cnames.empty?
        value
      end
    end
  end

  #: (Value) -> void
  def delete_by_value(value)
    @mutex.synchronize do
      @map.delete_if do |mod, cnames|
        cnames.delete_if { _2 == value }
        cnames.empty?
      end
    end
  end

  # Order of yielded crefs is undefined.
  #
  #: () { (Zeitwerk::Cref) -> void } -> void
  def each_key
    @mutex.synchronize do
      @map.each do |mod, cnames|
        cnames.each_key do |cname|
          yield Zeitwerk::Cref.new(mod, cname)
        end
      end
    end
  end

  #: () -> void
  def clear
    @mutex.synchronize do
      @map.clear
    end
  end

  #: () -> bool
  def empty? # for tests
    @mutex.synchronize do
      @map.empty?
    end
  end
end