lib/cattri/attribute_options.rb



# frozen_string_literal: true

require_relative "error"

module Cattri
  # @internal
  #
  # AttributeOptions encapsulates normalized metadata for a single Cattri-defined attribute.
  #
  # It validates, transforms, and freezes all input during initialization,
  # ensuring attribute safety and immutability at runtime.
  #
  # @example
  #   options = AttributeOptions.new(:enabled, default: true, expose: :read_write)
  #   options.name         # => :enabled
  #   options.default.call # => true
  #   options.expose       # => :read_write
  class AttributeOptions
    class << self
      # Validates and normalizes the `expose` configuration.
      #
      # @param expose [Symbol, String] one of: :read, :write, :read_write, :none
      # @return [Symbol]
      # @raise [Cattri::AttributeError] if the value is invalid
      def validate_expose!(expose)
        expose = expose.to_sym
        return expose if EXPOSE_OPTIONS.include?(expose) # steep:ignore

        raise Cattri::AttributeError, "Invalid expose option `#{expose.inspect}` for :#{name}"
      end

      # Validates and normalizes method visibility.
      #
      # @param visibility [Symbol, String] one of: :public, :protected, :private
      # @return [Symbol]
      # @raise [Cattri::AttributeError] if the value is invalid
      def validate_visibility!(visibility)
        visibility = visibility.to_sym
        return visibility if VISIBILITIES.include?(visibility) # steep:ignore

        raise Cattri::AttributeError, "Invalid visibility `#{visibility.inspect}` for :#{name}"
      end
    end

    # Valid method visibility levels.
    VISIBILITIES = %i[public protected private].freeze

    # Valid expose options for method generation.
    EXPOSE_OPTIONS = %i[read write read_write none].freeze

    # Valid scope types.
    SCOPES = %i[class instance].freeze
    private_constant :SCOPES

    # Built-in Ruby value types that are safe to reuse as-is (no dup needed).
    SAFE_VALUE_TYPES = [Numeric, Symbol, TrueClass, FalseClass, NilClass].freeze
    private_constant :SAFE_VALUE_TYPES

    attr_reader :name, :ivar, :final, :scope, :predicate,
                :default, :transformer, :expose, :visibility

    # Initializes a frozen attribute configuration.
    #
    # @param name [Symbol, String] the attribute name
    # @param ivar [Symbol, String, nil] optional custom instance variable name
    # @param final [Boolean] marks the attribute as write-once
    # @param scope [Symbol] indicates if the attribute is class-level (:class) or instance-level (:instance)
    # @param predicate [Boolean] whether to define a `?` predicate method
    # @param default [Object, Proc, nil] default value or callable
    # @param transformer [Proc, nil] optional coercion block
    # @param expose [Symbol] access level to define (:read, :write, :read_write, :none)
    # @param visibility [Symbol] method visibility (:public, :protected, :private)
    def initialize(
      name,
      ivar: nil,
      final: false,
      scope: :instance,
      predicate: false,
      default: nil,
      transformer: nil,
      expose: :read_write,
      visibility: :public
    )
      @name = name.to_sym
      @ivar = normalize_ivar(ivar)
      @final = final
      @scope = validate_scope!(scope)
      @predicate = predicate
      @default = normalize_default(default)
      @transformer = normalize_transformer(transformer)
      @expose = self.class.validate_expose!(expose)
      @visibility = self.class.validate_visibility!(visibility)

      freeze
    end

    # Returns a frozen hash representation of this option set.
    #
    # @return [Hash<Symbol, Object>]
    def to_h
      hash = {
        name: @name,
        ivar: @ivar,
        final: @final,
        scope: @scope,
        predicate: @predicate,
        default: @default,
        transformer: @transformer,
        expose: @expose,
        visibility: @visibility
      }
      hash.freeze
      hash
    end

    # Allows hash-style access to the option set.
    #
    # @param key [Symbol, String]
    # @return [Object]
    def [](key)
      to_h[key.to_sym]
    end

    private

    # Normalizes the instance variable name, defaulting to @name.
    #
    # @param ivar [String, Symbol, nil]
    # @return [Symbol]
    def normalize_ivar(ivar)
      ivar ||= name
      :"@#{ivar.to_s.delete_prefix("@")}"
    end

    # Wraps the default in a Proc with immutability protection.
    #
    # - Returns original Proc if given.
    # - Wraps immutable types as-is.
    # - Duplicates mutable values at runtime.
    #
    # @param default [Object, Proc, nil]
    # @return [Proc]
    def normalize_default(default)
      return default if default.is_a?(Proc)
      return -> { default } if default.frozen? || SAFE_VALUE_TYPES.any? { |t| default.is_a?(t) }

      -> { default.dup }
    end

    # Returns a normalized assignment transformer.
    #
    # Falls back to a default transformer that returns:
    # - `kwargs` if `args.empty?`
    # - the single argument if one is passed
    # - `[*args, kwargs]` otherwise
    #
    # @param transformer [Proc, nil]
    # @return [Proc]
    def normalize_transformer(transformer)
      transformer || lambda { |*args, **kwargs|
        return kwargs if args.empty?
        return args.length == 1 ? args[0] : args if kwargs.empty?

        [*args, kwargs]
      }
    end

    # Validates and normalizes the provided scope value.
    #
    # If `scope` is `nil`, it defaults to `:instance`. If it's one of the allowed
    # values (`:class`, `:instance`), it is returned as-is. Otherwise, an error is raised.
    #
    # @param scope [Symbol, nil] the requested attribute scope
    # @return [Symbol] the validated scope (`:class` or `:instance`)
    # @raise [Cattri::AttributeError] if the scope is invalid
    def validate_scope!(scope)
      return :instance if scope.nil?
      return scope if SCOPES.include?(scope)

      raise Cattri::AttributeError, "Invalid scope `#{scope.inspect}` for :#{name}. Must be :class or :instance"
    end
  end
end