lib/zeitwerk/loader/helpers.rb



# frozen_string_literal: true

module Zeitwerk::Loader::Helpers
  # --- Logging -----------------------------------------------------------------------------------

  # @sig (String) -> void
  private def log(message)
    method_name = logger.respond_to?(:debug) ? :debug : :call
    logger.send(method_name, "Zeitwerk@#{tag}: #{message}")
  end

  # --- Files and directories ---------------------------------------------------------------------

  # @sig (String) { (String, String) -> void } -> void
  private def ls(dir)
    children = Dir.children(dir)

    # The order in which a directory is listed depends on the file system.
    #
    # Since client code may run in different platforms, it seems convenient to
    # order directory entries. This provides consistent eager loading across
    # platforms, for example.
    children.sort!

    children.each do |basename|
      next if hidden?(basename)

      abspath = File.join(dir, basename)
      next if ignored_path?(abspath)

      if dir?(abspath)
        next if roots.key?(abspath)
        next if !has_at_least_one_ruby_file?(abspath)
      else
        next unless ruby?(abspath)
      end

      # We freeze abspath because that saves allocations when passed later to
      # File methods. See #125.
      yield basename, abspath.freeze
    end
  end

  # @sig (String) -> bool
  private def has_at_least_one_ruby_file?(dir)
    to_visit = [dir]

    while dir = to_visit.shift
      ls(dir) do |_basename, abspath|
        if dir?(abspath)
          to_visit << abspath
        else
          return true
        end
      end
    end

    false
  end

  # @sig (String) -> bool
  private def ruby?(path)
    path.end_with?(".rb")
  end

  # @sig (String) -> bool
  private def dir?(path)
    File.directory?(path)
  end

  # @sig (String) -> bool
  private def hidden?(basename)
    basename.start_with?(".")
  end

  # @sig (String) { (String) -> void } -> void
  private def walk_up(abspath)
    loop do
      yield abspath
      abspath, basename = File.split(abspath)
      break if basename == "/"
    end
  end

  # --- Constants ---------------------------------------------------------------------------------

  # The autoload? predicate takes into account the ancestor chain of the
  # receiver, like const_defined? and other methods in the constants API do.
  #
  # For example, given
  #
  #   class A
  #     autoload :X, "x.rb"
  #   end
  #
  #   class B < A
  #   end
  #
  # B.autoload?(:X) returns "x.rb".
  #
  # We need a way to strictly check in parent ignoring ancestors.
  #
  # @sig (Module, Symbol) -> String?
  if method(:autoload?).arity == 1
    private def strict_autoload_path(parent, cname)
      parent.autoload?(cname) if cdef?(parent, cname)
    end
  else
    private def strict_autoload_path(parent, cname)
      parent.autoload?(cname, false)
    end
  end

  # @sig (Module, Symbol) -> String
  if Symbol.method_defined?(:name)
    # Symbol#name was introduced in Ruby 3.0. It returns always the same
    # frozen object, so we may save a few string allocations.
    private def cpath(parent, cname)
      Object == parent ? cname.name : "#{real_mod_name(parent)}::#{cname.name}"
    end
  else
    private def cpath(parent, cname)
      Object == parent ? cname.to_s : "#{real_mod_name(parent)}::#{cname}"
    end
  end

  # @sig (Module, Symbol) -> bool
  private def cdef?(parent, cname)
    parent.const_defined?(cname, false)
  end

  # @raise [NameError]
  # @sig (Module, Symbol) -> Object
  private def cget(parent, cname)
    parent.const_get(cname, false)
  end

  # @raise [NameError]
  # @sig (Module, Symbol) -> Object
  private def crem(parent, cname)
    parent.__send__(:remove_const, cname)
  end

  CNAME_VALIDATOR = Module.new
  private_constant :CNAME_VALIDATOR

  # @raise [Zeitwerk::NameError]
  # @sig (String, String) -> Symbol
  private def cname_for(basename, abspath)
    cname = inflector.camelize(basename, abspath)

    unless cname.is_a?(String)
      raise TypeError, "#{inflector.class}#camelize must return a String, received #{cname.inspect}"
    end

    if cname.include?("::")
      raise Zeitwerk::NameError.new(<<~MESSAGE, cname)
        wrong constant name #{cname} inferred by #{inflector.class} from

          #{abspath}

        #{inflector.class}#camelize should return a simple constant name without "::"
      MESSAGE
    end

    begin
      CNAME_VALIDATOR.const_defined?(cname, false)
    rescue ::NameError => error
      path_type = ruby?(abspath) ? "file" : "directory"

      raise Zeitwerk::NameError.new(<<~MESSAGE, error.name)
        #{error.message} inferred by #{inflector.class} from #{path_type}

          #{abspath}

        Possible ways to address this:

          * Tell Zeitwerk to ignore this particular #{path_type}.
          * Tell Zeitwerk to ignore one of its parent directories.
          * Rename the #{path_type} to comply with the naming conventions.
          * Modify the inflector to handle this case.
      MESSAGE
    end

    cname.to_sym
  end
end