lib/xcodeproj/project/object_attributes.rb



module Xcodeproj
  class Project
    module Object

      # This class represents an attribute of {AbstractObject} subclasses.
      # Attributes are created by the {AbstractObject} DSL methods and allow to
      # mirror the underlying attributes of the xcodeproj document model.
      #
      # Attributes provide support for runtime type checking. They also allow
      # {AbstractObject} initialization and serialization to plist.
      #
      # @todo Add support for a list of required values so objects can be
      #       validated before serialization ?
      #
      class AbstractObjectAttribute

        require 'active_support/inflector'

        # @return [Symbol] the type of the attribute. It can be `:simple`,
        #   `:to_one`, `:to_many`.
        #
        attr_reader :type

        # @return [Symbol] the name of the attribute.
        #
        attr_reader :name

        # @return [Class] the class that owns the attribute.
        #
        attr_accessor :owner

        # Creates a new attribute with the given type and name.
        #
        # Attributes are expected to be instantiated only by the
        # {AbstractObject} DSL methods.
        #
        # @param [Symbol] type
        # the type of the attribute.
        #
        # @param [Symbol] name
        #   the name of the attribute.
        #
        # @param [Class] owner
        #   the class that owns the attribute.
        #
        def initialize(type, name, owner)
          @type  =  type
          @name  =  name
          @owner =  owner
        end

        # @return[String] The name of the attribute in camel case.
        #
        # @example
        #   attribute.new(:simple, project_root)
        #   attribute.plist_name #=> projectRoot
        #
        def plist_name
          case name
          when :remote_global_id_string
            # `_id_` would become `Id`
            'remoteGlobalIDString'
          else
            name.to_s.camelize(:lower)
          end
        end

        # @return [Array<Class>] the list of the classes accepted by the
        #   attribute.
        #
        attr_accessor :classes

        # @return [String, Array, Hash] the default value, if any, for simple
        #   attributes.
        #
        attr_accessor :default_value

        # Convenience method that returns the value of this attribute for a
        #   given object.
        #
        # @param [AbstractObject] object
        #   the object for which the value of this attribute is requested.
        #
        # @return [String, Array, Hash, AbstractObject, ObjectList]
        #   the value.
        #
        def get_value(object)
          object.send("#{name}")
        end

        # Convenience method that sets the value of this attribute for a
        #   given object. It makes sense only for `:simple` or `:to_one`
        #   attributes.
        #
        # @raise It the type of this attribute is `:to_many`.
        #
        # @param [AbstractObject] object
        #   the object for which to set the value.
        #
        # @param [String, Hash, Array, AbstractObject] new_value
        #   the value to set for the attribute.
        #
        # @return [void]
        #
        def set_value(object, new_value)
          raise "[Xcodeproj] Set value called for to many attribute" if type == :to_many
          object.send("#{name}=", new_value)
        end

        # Convenience method that sets the value of this attribute for a
        #   given object to the default (if any).  It makes sense only for
        #   `:simple` attributes.
        #
        # @param [AbstractObject] object
        #   the object for which to set the default value.
        #
        # @return [void]
        #
        def set_default(object)
          raise "[Xcodeproj] Set value called for to many attribute" unless type == :simple
          set_value(object, default_value) if default_value
        end

        # Checks that a given value is compatible with the attribute.
        #
        # This method powers the runtime type checking of the {AbstractObject}
        # and is used its by synthesised methods.
        #
        # @raise If the class of the value is not compatible with the attribute.
        #
        # @return [void]
        #
        def validate_value(object)
          return unless object
          acceptable = classes.find { |klass| object.class == klass || object.class < klass }
          if type == :simple
            raise "[Xcodeproj] Type checking error: '#{owner.isa}' expected '#{classes.inspect}' got '#{object.class}' for attribute: #{inspect}" unless acceptable
          else
            raise "[Xcodeproj] Type checking error: '#{owner.isa}' expected #{classes.map(&:isa)} got #{object.isa} for attribute: #{inspect}" unless acceptable
          end
        end

        def inspect
          "#<name: '#{name}', type: '#{type}', classes: '#{classes}', owner class: '#{owner.isa}'>"
        end
      end

      class AbstractObject

        # The {AbstractObject} DSL methods allow to specify with fidelity the
        # underlying model of the xcodeproj document format. {AbstractObject}
        # subclasses should specify their attributes through the following
        # methods:
        #
        # - `{AbstractObject.attribute}`
        # - `{AbstractObject.has_one}`
        # - `{AbstractObject.has_many}`
        #
        # The subclasses should not interfere with the methods synthesised by
        # the DSL and should only implement convenience methods in top of them.
        #
        # Attributes are typed and are validated at runtime.
        #
        class << self

          # @return [Array<AbstractObjectAttribute>] the attributes associated
          #   with the class.
          #
          # @note It includes the attributes defined in the superclass and the
          #   list is cleaned for duplicates. Subclasses should not duplicate
          #   an attribute of the superclass but for the method implementation
          #   they will duplicate them.
          #
          def attributes
            unless @full_attributes
              attributes = @attributes || []
              super_attributes = superclass.respond_to?(:attributes) ? superclass.attributes : []
              # The uniqueness of the attributes is very important because the
              # initialization from plist deletes the values from the dictionary.
              @full_attributes = attributes.concat(super_attributes).uniq
            end
            @full_attributes
          end

          # @return [Array<AbstractObjectAttribute>] the simple attributes
          #   associated with with the class.
          #
          def simple_attributes
            @simple_attributes ||= attributes.select { |a| a.type == :simple }
          end

          # @return [Array<AbstractObjectAttribute>] the attributes
          #   representing a to one relationship associated with with the
          #   class.
          #
          def to_one_attributes
            @to_one_attributes ||= attributes.select { |a| a.type == :to_one }
          end

          # @return [Array<AbstractObjectAttribute>] the attributes
          #   representing a to many relationship associated with with the
          #   class.
          #
          def to_many_attributes
            @to_many_attributes ||= attributes.select { |a| a.type == :to_many }
          end

          private

          # Defines a new simple attribute and synthesises the corresponding
          # methods.
          #
          # @note Simple attributes are directly stored in a hash. They can
          #       contain only a string, array of strings or a hash containing
          #       strings and thus they are not affected by reference counting.
          #       Clients can access the hash directly through the
          #       {AbstractObject#simple_attributes_hash} method.
          #
          # @param [Symbol] name
          #   the name of the attribute.
          #
          # @param [Class] klass
          #   the accepted {Class} for the values of the attribute.
          #
          # @param [String, Array<String>, Hash{String=>String}] default_value
          #   the default value for new objects.
          #
          # @example
          #   attribute project_root
          #   #=> leads to the creation of the following methods
          #
          #   def project_root
          #     @simple_attributes_hash[projectRoot]
          #   end
          #
          #   def project_root=(value)
          #     attribute.validate_value(value)
          #     @simple_attributes_hash[projectRoot] = value
          #   end
          #
          # @macro [attach] attribute
          #   @!attribute [rw] $1
          #
          def attribute(name, klass, default_value = nil)
            attrb = AbstractObjectAttribute.new(:simple, name, self)
            attrb.classes = [klass]
            attrb.default_value = default_value
            add_attribute(attrb)

            define_method(attrb.name) do
              @simple_attributes_hash ||= {}
              @simple_attributes_hash[attrb.plist_name]
            end

            define_method("#{attrb.name}=") do |value|
              @simple_attributes_hash ||= {}
              attrb.validate_value(value)
              @simple_attributes_hash[attrb.plist_name] = value
            end
          end

          # Defines a new relationship to a single and synthesises the
          # corresponding methods.
          #
          # @note The synthesised setter takes care of handling reference
          #       counting directly.
          #
          # @param [String] singular_name
          #   the name of the relationship.
          #
          # @param [Class, Array<Class>] isas
          #   the list of the classes corresponding to the accepted isas for
          #   this relationship.
          #
          # @macro [attach] has_one
          #   @!attribute [rw] $1
          #
          def has_one(singular_name, isas)
            isas = [isas] unless isas.is_a?(Array)
            attrb = AbstractObjectAttribute.new(:to_one, singular_name, self)
            attrb.classes = isas
            add_attribute(attrb)

            attr_reader(attrb.name)

            define_method("#{attrb.name}=") do |value|
              attrb.validate_value(value)

              previous_value = send(attrb.name)
              previous_value.remove_referrer(self) if previous_value
              instance_variable_set("@#{attrb.name}", value)
              value.add_referrer(self) if value
            end
          end

          # Defines a new ordered relationship to many.
          #
          # @note This attribute only generates the reader method. Clients are
          #       not supposed to create {ObjectList} objects which are created
          #       by the methods synthesised by this attribute on demand.
          #       Clients, however can mutate the list according to its
          #       interface. The list is responsible to manage the reference
          #       counting for its values.
          #
          # @param [String] plural_name
          #   the name of the relationship.
          #
          # @param [Class, Array<Class>] isas
          #   the list of the classes corresponding to the accepted isas for
          #   this relationship.
          #
          # @macro [attach] has_many
          #   @!attribute [r] $1
          #
          def has_many(plural_name, isas)
            isas = [isas] unless isas.is_a?(Array)

            attrb = AbstractObjectAttribute.new(:to_many, plural_name, self)
            attrb.classes = isas
            add_attribute(attrb)

            define_method(attrb.name) do
              # Here we are in the context of the instance
              list = instance_variable_get("@#{attrb.name}")
              unless list
                list = ObjectList.new(attrb, self)
                instance_variable_set("@#{attrb.name}", list)
              end
              list
            end
          end

          protected

          # Adds an attribute to the list of attributes of the class.
          #
          # @note This method is intended to be invoked only by the
          #       {AbstractObject} meta programming methods
          #
          # @return [void]
          #
          def add_attribute(attribute)
            raise "[Xcodeproj] BUG - missing classes for #{attribute.inspect}" unless attribute.classes
            raise "[Xcodeproj] BUG - classes:#{attribute.classes} for #{attribute.inspect}" unless attribute.classes.all? { |klass| klass.is_a?(Class) }
            @attributes ||= []
            @attributes << attribute
          end
        end # AbstractObject << self

        # @return [Hash] the simple attributes hash.
        #
        attr_reader :simple_attributes_hash

        # @!group xcodeproj format attributes

        # @return (see AbstractObject.attributes)
        #
        def attributes
          self.class.attributes
        end

        # @return (see AbstractObject.simple_attributes)
        #
        def simple_attributes
          self.class.simple_attributes
        end

        # @return (see AbstractObject.to_one_attributes)
        #
        def to_one_attributes
          self.class.to_one_attributes
        end

        # @return (see AbstractObject.to_many_attributes)
        #
        def to_many_attributes
          self.class.to_many_attributes
        end
      end # AbstractObject
    end
  end
end