lib/ivar.rb



# frozen_string_literal: true

require_relative "ivar/version"
require_relative "ivar/policies"
require_relative "ivar/validation"
require_relative "ivar/macros"
require_relative "ivar/project_root"
require_relative "ivar/check_all_manager"
require_relative "ivar/check_policy"
require_relative "ivar/checked"
require_relative "ivar/manifest"
require_relative "ivar/targeted_prism_analysis"
require "prism"
require "did_you_mean"
require "pathname"

module Ivar
  @analysis_cache = {}
  @checked_classes = {}
  @default_check_policy = :warn_once
  @manifest_registry = {}
  @project_root = nil
  MUTEX = Mutex.new
  PROJECT_ROOT_FINDER = ProjectRoot.new
  CHECK_ALL_MANAGER = CheckAllManager.new

  # Pattern for internal instance variables
  INTERNAL_IVAR_PREFIX = "@__ivar_"

  # Checks if an instance variable name is an internal variable
  # @param ivar_name [Symbol, String] The instance variable name to check
  # @return [Boolean] Whether the variable is an internal variable
  def self.internal_ivar?(ivar_name)
    ivar_name.to_s.start_with?(INTERNAL_IVAR_PREFIX)
  end

  # Returns a list of known internal instance variables
  # @return [Array<Symbol>] List of known internal instance variables
  def self.known_internal_ivars
    [
      :@__ivar_check_policy,
      :@__ivar_initialized_vars,
      :@__ivar_method_impl_stash,
      :@__ivar_skip_init
    ]
  end

  def self.get_ancestral_analyses(klass)
    klass
      .ancestors.filter_map { |ancestor| maybe_get_analysis(ancestor) }
      .reverse
  end

  def self.maybe_get_analysis(klass)
    if klass.include?(Validation)
      get_analysis(klass)
    end
  end

  # Returns a cached analysis for the given class or module
  # Creates a new analysis if one doesn't exist in the cache
  # Thread-safe: Multiple readers are allowed, but writers block all other access
  def self.get_analysis(klass)
    return @analysis_cache[klass] if @analysis_cache.key?(klass)

    MUTEX.synchronize do
      @analysis_cache[klass] ||= TargetedPrismAnalysis.new(klass)
    end
  end

  # Checks if a class has been validated already
  # @param klass [Class] The class to check
  # @return [Boolean] Whether the class has been validated
  # Thread-safe: Read-only operation
  def self.class_checked?(klass)
    MUTEX.synchronize { @checked_classes.key?(klass) }
  end

  # Marks a class as having been checked
  # @param klass [Class] The class to mark as checked
  # Thread-safe: Write operation protected by mutex
  def self.mark_class_checked(klass)
    MUTEX.synchronize { @checked_classes[klass] = true }
  end

  # For testing purposes - allows clearing the cache
  # Thread-safe: Write operation protected by mutex
  def self.clear_analysis_cache
    MUTEX.synchronize do
      @analysis_cache.clear
      @checked_classes.clear
      @manifest_registry.clear
    end
    PROJECT_ROOT_FINDER.clear_cache
  end

  # Get or create a manifest for a class or module
  # @param klass [Class, Module] The class or module to get a manifest for
  # @param create [Boolean] Whether to create a new manifest if one doesn't exist
  # @return [Manifest, nil] The manifest for the class or module, or nil if not found and create_if_missing is false
  def self.get_manifest(klass, create: true)
    return @manifest_registry[klass] if @manifest_registry.key?(klass)
    return nil unless create

    MUTEX.synchronize do
      @manifest_registry[klass] ||= Manifest.new(klass)
    end
  end

  # Alias for get_manifest that makes it clearer that it may create a manifest
  # @param klass [Class, Module] The class or module to get a manifest for
  # @return [Manifest] The manifest for the class or module
  def self.get_or_create_manifest(klass)
    get_manifest(klass, create: true)
  end

  # Check if a manifest exists for a class or module
  # @param klass [Class, Module] The class or module to check
  # @return [Boolean] Whether a manifest exists for the class or module
  def self.manifest_exists?(klass)
    @manifest_registry.key?(klass)
  end

  # Get the default check policy
  # @return [Symbol] The default check policy
  def self.check_policy
    @default_check_policy
  end

  # Set the default check policy
  # @param policy [Symbol, Policy] The default check policy
  def self.check_policy=(policy)
    MUTEX.synchronize { @default_check_policy = policy }
  end

  def self.project_root=(explicit_root)
    @project_root = explicit_root
  end

  # Determines the project root directory based on the caller's location
  # Delegates to ProjectRoot class
  # @param caller_location [String, nil] Optional file path to start from (defaults to caller's location)
  # @return [String] The absolute path to the project root directory
  def self.project_root(caller_location = nil)
    @project_root ||= PROJECT_ROOT_FINDER.find(caller_location)
  end

  # Enables automatic inclusion of Ivar::Checked in all classes and modules
  # defined within the project root.
  #
  # @param block [Proc] Optional block. If provided, auto-checking is only active
  #   for the duration of the block. Otherwise, it remains active indefinitely.
  # @return [void]
  def self.check_all(&block)
    root = project_root
    CHECK_ALL_MANAGER.enable(root, &block)
  end

  # Disables automatic inclusion of Ivar::Checked in classes and modules.
  # @return [void]
  def self.disable_check_all
    CHECK_ALL_MANAGER.disable
  end

  # Gets a method from the stash or returns nil if not found
  # @param klass [Class] The class that owns the method
  # @param method_name [Symbol] The name of the method to retrieve
  # @return [UnboundMethod, nil] The stashed method or nil if not found
  def self.get_stashed_method(klass, method_name)
    (klass.instance_variable_get(:@__ivar_method_impl_stash) || {})[method_name]
  end

  # Stashes a method implementation for a class
  # @param klass [Class] The class that owns the method
  # @param method_name [Symbol] The name of the method to stash
  # @return [UnboundMethod, nil] The stashed method or nil if the method doesn't exist
  def self.stash_method(klass, method_name)
    return nil unless klass.method_defined?(method_name) || klass.private_method_defined?(method_name)

    method_impl = klass.instance_method(method_name)
    stash = klass.instance_variable_get(:@__ivar_method_impl_stash) ||
      klass.instance_variable_set(:@__ivar_method_impl_stash, {})
    stash[method_name] = method_impl
  end
end