lib/bindata/struct.rb



require 'bindata/base'
require 'bindata/sanitize'

module BinData
  # A Struct is an ordered collection of named data objects.
  #
  #    require 'bindata'
  #
  #    class Tuple < BinData::MultiValue
  #      int8  :x
  #      int8  :y
  #      int8  :z
  #    end
  #
  #    obj = BinData::Struct.new(:hide => :a,
  #                              :fields => [ [:int32le, :a],
  #                                           [:int16le, :b],
  #                                           [:tuple, :s] ])
  #    obj.field_names   =># ["b", "s"]
  #
  #
  # == Parameters
  #
  # Parameters may be provided at initialisation to control the behaviour of
  # an object.  These params are:
  #
  # <tt>:fields</tt>::   An array specifying the fields for this struct.
  #                      Each element of the array is of the form [type, name,
  #                      params].  Type is a symbol representing a registered
  #                      type.  Name is the name of this field.  Params is an
  #                      optional hash of parameters to pass to this field
  #                      when instantiating it.
  # <tt>:hide</tt>::     A list of the names of fields that are to be hidden
  #                      from the outside world.  Hidden fields don't appear
  #                      in #snapshot or #field_names but are still accessible
  #                      by name.
  # <tt>:endian</tt>::   Either :little or :big.  This specifies the default
  #                      endian of any numerics in this struct, or in any
  #                      nested data objects.
  class Struct < BinData::Base

    # These reserved words may not be used as field names
    RESERVED = (::Hash.instance_methods + 
                %w{alias and begin break case class def defined do else elsif
                   end ensure false for if in module next nil not or redo
                   rescue retry return self super then true undef unless until
                   when while yield} +
                %w{array element index offset value} ).uniq

    # Register this class
    register(self.name, self)

    # A hash that can be accessed via attributes.
    class Snapshot < Hash #:nodoc:
      def method_missing(symbol, *args)
        self[symbol.id2name] || super
      end
    end

    class << self
      #### DEPRECATION HACK to warn about inheriting from BinData::Struct
      #
      def inherited(subclass) #:nodoc:
        if subclass != MultiValue
          # warn about deprecated method - remove before releasing 1.0
          fail "error: inheriting from BinData::Struct has been deprecated. Inherit from BinData::MultiValue instead."
        end
      end
      #
      #### DEPRECATION HACK to allow inheriting from BinData::Struct


      # Ensures that +params+ is of the form expected by #initialize.
      def sanitize_parameters!(sanitizer, params)
        # possibly override endian
        endian = params[:endian]
        if endian != nil
          unless [:little, :big].include?(endian)
            raise ArgumentError, "unknown value for endian '#{endian}'"
          end

          params[:endian] = endian
        end

        if params.has_key?(:fields)
          sanitizer.with_endian(endian) do
            # ensure names of fields are strings and that params is sanitized
            all_fields = params[:fields].collect do |ftype, fname, fparams|
              fname = fname.to_s
              klass = sanitizer.lookup_klass(ftype)
              sanitized_fparams = sanitizer.sanitize_params(klass, fparams)
              [klass, fname, sanitized_fparams]
            end
            params[:fields] = all_fields
          end

          # now params are sanitized, check that parameter names are okay
          field_names = []
          instance_methods = self.instance_methods
          reserved_names = RESERVED

          params[:fields].each do |fklass, fname, fparams|

            # check that name doesn't shadow an existing method
            if instance_methods.include?(fname)
              raise NameError.new("Rename field '#{fname}' in #{self}, " +
                                  "as it shadows an existing method.", fname)
            end

            # check that name isn't reserved
            if reserved_names.include?(fname)
              raise NameError.new("Rename field '#{fname}' in #{self}, " +
                                  "as it is a reserved name.", fname)
            end

            # check for multiple definitions
            if field_names.include?(fname)
              raise NameError.new("field '#{fname}' in #{self}, " +
                                  "is defined multiple times.", fname)
            end

            field_names << fname
          end

          # collect all hidden names that correspond to a field name
          hide = []
          if params.has_key?(:hide)
            hidden = (params[:hide] || []).collect { |h| h.to_s }
            all_field_names = params[:fields].collect { |k,n,p| n }
            hide = hidden & all_field_names
          end
          params[:hide] = hide
        end

        super(sanitizer, params)
      end
    end

    # These are the parameters used by this class.
    bindata_mandatory_parameter :fields
    bindata_optional_parameters :endian, :hide

    # Creates a new Struct.
    def initialize(params = {}, parent = nil)
      super(params, parent)

      # extract field names but don't instantiate the fields
      @field_names = no_eval_param(:fields).collect { |k, n, p| n }
      @field_objs  = []
    end

    # Clears the field represented by +name+.  If no +name+
    # is given, clears all fields in the struct.
    def clear(name = nil)
      if name.nil?
        @field_objs.each { |f| f.clear unless f.nil? }
      else
        obj = find_obj_for_name(name.to_s)
        obj.clear unless obj.nil?
      end
    end

    # Returns if the field represented by +name+ is clear?.  If no +name+
    # is given, returns whether all fields are clear.
    def clear?(name = nil)
      if name.nil?
        @field_objs.each do |f|
          return false unless f.nil? or f.clear?
        end
        true
      else
        obj = find_obj_for_name(name.to_s)
        obj.nil? ? true : obj.clear?
      end
    end

    # Returns whether this data object contains a single value.  Single
    # value data objects respond to <tt>#value</tt> and <tt>#value=</tt>.
    def single_value?
      return false
    end

    # Returns a list of the names of all fields accessible through this
    # object.  +include_hidden+ specifies whether to include hidden names
    # in the listing.
    def field_names(include_hidden = false)
      # collect field names
      names = []
      hidden = no_eval_param(:hide)
      @field_names.each do |name|
        if include_hidden or not hidden.include?(name)
          names << name
        end
      end
      names
    end

    # To be called after calling #read.
    def done_read
      @field_objs.each { |f| f.done_read unless f.nil? }
    end

    def offset_of(field)
      idx = @field_names.index(field.to_s)
      if idx
        instantiate_all

        offset = 0
        (0...idx).each do |i|
          this_offset = @field_objs[i].do_num_bytes
          if ::Float === offset and ::Integer === this_offset
            offset = offset.ceil
          end
          offset += this_offset
        end
        offset
      else
        nil
      end
    end

    # Override to include field names
    alias_method :orig_respond_to?, :respond_to?
    def respond_to?(symbol, include_private = false)
      orig_respond_to?(symbol, include_private) ||
        field_names(true).include?(symbol.id2name.chomp("="))
    end

    def method_missing(symbol, *args, &block)
      name = symbol.id2name

      is_writer = (name[-1, 1] == "=")
      name.chomp!("=")

      # find the object that is responsible for name
      if (obj = find_obj_for_name(name))
        # pass on the request
        if obj.single_value? and is_writer
          obj.value = *args
        elsif obj.single_value?
          obj.value
        else
          obj
        end
      else
        super
      end
    end

    #---------------
    private

    # Returns the data object that stores values for +name+.
    def find_obj_for_name(name)
      idx = @field_names.index(name)
      if idx
        instantiate_obj(idx)
        @field_objs[idx].obj
      else
        nil
      end
    end

    # Instantiates all fields.
    def instantiate_all
      @field_names.each_with_index { |name, i| instantiate_obj(i) }
    end

    # Instantiates the field object at position +idx+.
    def instantiate_obj(idx)
      if @field_objs[idx].nil?
        fklass, fname, fparams = no_eval_param(:fields)[idx]
        @field_objs[idx] = fklass.new(fparams, self)
      end
    end

    # Reads the values for all fields in this object from +io+.
    def _do_read(io)
      instantiate_all
      @field_objs.each { |f| f.do_read(io) }
    end

    # Writes the values for all fields in this object to +io+.
    def _do_write(io)
      instantiate_all
      @field_objs.each { |f| f.do_write(io) }
    end

    # Returns the number of bytes it will take to write the field represented
    # by +name+.  If +name+ is nil then returns the number of bytes required
    # to write all fields.
    def _do_num_bytes(name)
      if name.nil?
        instantiate_all
        (@field_objs.inject(0) { |sum, f| sum + f.do_num_bytes }).ceil
      else
        obj = find_obj_for_name(name.to_s)
        obj.nil? ? 0 : obj.do_num_bytes
      end
    end

    # Returns a snapshot of this struct as a hash.
    def _snapshot
      hash = Snapshot.new
      field_names.each do |name|
        ss = find_obj_for_name(name).snapshot
        hash[name] = ss unless ss.nil?
      end
      hash
    end
  end
end