dmendel/bindata

View on GitHub
lib/bindata/dsl.rb

Summary

Maintainability
C
1 day
Test Coverage
module BinData
  # Extracts args for Records and Buffers.
  #
  # Foo.new(bar: "baz) is ambiguous as to whether :bar is a value or parameter.
  #
  # BaseArgExtractor always assumes :bar is parameter.  This extractor correctly
  # identifies it as value or parameter.
  module MultiFieldArgSeparator
    def separate_args(obj_class, obj_args)
      value, parameters, parent = super(obj_class, obj_args)

      if parameters_is_value?(obj_class, value, parameters)
        value = parameters
        parameters = {}
      end

      [value, parameters, parent]
    end

    def parameters_is_value?(obj_class, value, parameters)
      if value.nil? && !parameters.empty?
        field_names_in_parameters?(obj_class, parameters)
      else
        false
      end
    end

    def field_names_in_parameters?(obj_class, parameters)
      field_names = obj_class.fields.field_names
      param_keys = parameters.keys

      !(field_names & param_keys).empty?
    end
  end

  # BinData classes that are part of the DSL must be extended by this.
  module DSLMixin
    def dsl_parser(parser_type = nil)
      @dsl_parser ||= begin
        parser_type ||= superclass.dsl_parser.parser_type
        DSLParser.new(self, parser_type)
      end
    end

    def method_missing(symbol, *args, &block) # :nodoc:
      dsl_parser.__send__(symbol, *args, &block)
    end

    # Assert object is not an array or string.
    def to_ary; nil; end
    def to_str; nil; end

    # A DSLParser parses and accumulates field definitions of the form
    #
    #   type name, params
    #
    # where:
    #   * +type+ is the under_scored name of a registered type
    #   * +name+ is the (possible optional) name of the field
    #   * +params+ is a hash containing any parameters
    #
    class DSLParser
      def initialize(the_class, parser_type)
        raise "unknown parser type #{parser_type}" unless parser_abilities[parser_type]

        @the_class      = the_class
        @parser_type    = parser_type
        @validator      = DSLFieldValidator.new(the_class, self)
        @endian         = nil
      end

      attr_reader :parser_type

      def endian(endian = nil)
        if endian
          set_endian(endian)
        elsif @endian.nil?
          set_endian(parent_attribute(:endian))
        end
        @endian
      end

      def search_prefix(*args)
        @search_prefix ||= parent_attribute(:search_prefix, []).dup

        prefix = args.collect(&:to_sym).compact
        unless prefix.empty?
          if fields?
            dsl_raise SyntaxError, "search_prefix must be called before defining fields"
          end

          @search_prefix = prefix.concat(@search_prefix)
        end

        @search_prefix
      end

      def hide(*args)
        if option?(:hidden_fields)
          @hide ||= parent_attribute(:hide, []).dup

          hidden = args.collect(&:to_sym).compact
          @hide.concat(hidden)

          @hide
        end
      end

      def fields
        @fields ||= SanitizedFields.new(hints, parent_fields)
      end

      def dsl_params
        abilities = parser_abilities[@parser_type]
        send(abilities.at(0), abilities.at(1))
      end

      def method_missing(*args, &block)
        ensure_hints
        parse_and_append_field(*args, &block)
      end

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

      def parser_abilities
        @abilities ||= {
          struct:     [:to_struct_params, :struct,      [:multiple_fields, :optional_fieldnames, :hidden_fields]],
          array:      [:to_object_params, :type,        [:multiple_fields, :optional_fieldnames]],
          buffer:     [:to_object_params, :type,        [:multiple_fields, :optional_fieldnames, :hidden_fields]],
          choice:     [:to_choice_params, :choices,     [:multiple_fields, :all_or_none_fieldnames, :fieldnames_are_values]],
          delayed_io: [:to_object_params, :type,        [:multiple_fields, :optional_fieldnames, :hidden_fields]],
          primitive:  [:to_struct_params, :struct,      [:multiple_fields, :optional_fieldnames]],
          section:    [:to_object_params, :type,        [:multiple_fields, :optional_fieldnames]],
          skip:       [:to_object_params, :until_valid, [:multiple_fields, :optional_fieldnames]]
        }
      end

      def option?(opt)
        parser_abilities[@parser_type].at(2).include?(opt)
      end

      def ensure_hints
        endian
        search_prefix
      end

      def hints
        { endian: endian, search_prefix: search_prefix }
      end

      def set_endian(endian)
        if endian
          if fields?
            dsl_raise SyntaxError, "endian must be called before defining fields"
          end
          if !valid_endian?(endian)
            dsl_raise ArgumentError, "unknown value for endian '#{endian}'"
          end

          if endian == :big_and_little
            DSLBigAndLittleEndianHandler.handle(@the_class)
          end

          @endian = endian
        end
      end

      def valid_endian?(endian)
        [:big, :little, :big_and_little].include?(endian)
      end

      def parent_fields
        parent_attribute(:fields)
      end

      def fields?
        defined?(@fields) && !@fields.empty?
      end

      def parse_and_append_field(*args, &block)
        parser = DSLFieldParser.new(hints, *args, &block)
        begin
          @validator.validate_field(parser.name)
          append_field(parser.type, parser.name, parser.params)
        rescue Exception => e
          dsl_raise e.class, e.message
        end
      end

      def append_field(type, name, params)
        fields.add_field(type, name, params)
      rescue BinData::UnRegisteredTypeError => e
        raise TypeError, "unknown type '#{e.message}'"
      end

      def parent_attribute(attr, default = nil)
        parent = @the_class.superclass
        parser = parent.respond_to?(:dsl_parser) ? parent.dsl_parser : nil
        if parser&.respond_to?(attr)
          parser.send(attr)
        else
          default
        end
      end

      def dsl_raise(exception, msg)
        backtrace = caller
        backtrace.shift while %r{bindata/dsl.rb}.match?(backtrace.first)

        raise exception, "#{msg} in #{@the_class}", backtrace
      end

      def to_object_params(key)
        case fields.length
        when 0
          {}
        when 1
          { key => fields[0].prototype }
        else
          { key => [:struct, to_struct_params] }
        end
      end

      def to_choice_params(key)
        if fields.empty?
          {}
        elsif fields.all_field_names_blank?
          { key => fields.collect(&:prototype) }
        else
          choices = {}
          fields.each { |f| choices[f.name] = f.prototype }
          { key => choices }
        end
      end

      def to_struct_params(*_)
        result = { fields: fields }
        if !endian.nil?
          result[:endian] = endian
        end
        if !search_prefix.empty?
          result[:search_prefix] = search_prefix
        end
        if option?(:hidden_fields) && !hide.empty?
          result[:hide] = hide
        end

        result
      end
    end

    # Handles the :big_and_little endian option.
    # This option creates two subclasses, each handling
    # :big or :little endian.
    class DSLBigAndLittleEndianHandler
      class << self
        def handle(bnl_class)
          make_class_abstract(bnl_class)
          create_subclasses_with_endian(bnl_class)
          override_new_in_class(bnl_class)
          delegate_field_creation(bnl_class)
          fixup_subclass_hierarchy(bnl_class)
        end

        def make_class_abstract(bnl_class)
          bnl_class.send(:unregister_self)
        end

        def create_subclasses_with_endian(bnl_class)
          instance_eval "class ::#{bnl_class}Be < ::#{bnl_class}; endian :big; end"
          instance_eval "class ::#{bnl_class}Le < ::#{bnl_class}; endian :little; end"
        end

        def override_new_in_class(bnl_class)
          endian_classes = {
            big:    class_with_endian(bnl_class, :big),
            little: class_with_endian(bnl_class, :little)
          }
          bnl_class.define_singleton_method(:new) do |*args|
            if self == bnl_class
              _, options, _ = arg_processor.separate_args(self, args)
              delegate = endian_classes[options[:endian]]
              return delegate.new(*args) if delegate
            end

            super(*args)
          end
        end

        def delegate_field_creation(bnl_class)
          endian_classes = {
            big:    class_with_endian(bnl_class, :big),
            little: class_with_endian(bnl_class, :little)
          }

          parser = bnl_class.dsl_parser
          parser.define_singleton_method(:parse_and_append_field) do |*args, &block|
            endian_classes[:big].send(*args, &block)
            endian_classes[:little].send(*args, &block)
          end
        end

        def fixup_subclass_hierarchy(bnl_class)
          parent = bnl_class.superclass
          return if obj_attribute(parent, :endian) != :big_and_little

          be_subclass = class_with_endian(bnl_class, :big)
          be_parent   = class_with_endian(parent, :big)
          be_fields   = obj_attribute(be_parent, :fields)

          le_subclass = class_with_endian(bnl_class, :little)
          le_parent   = class_with_endian(parent, :little)
          le_fields   = obj_attribute(le_parent, :fields)

          be_subclass.dsl_parser.define_singleton_method(:parent_fields) do
            be_fields
          end
          le_subclass.dsl_parser.define_singleton_method(:parent_fields) do
            le_fields
          end
        end

        def class_with_endian(class_name, endian)
          hints = {
            endian: endian,
            search_prefix: class_name.dsl_parser.search_prefix
          }
          RegisteredClasses.lookup(class_name, hints)
        end

        def obj_attribute(obj, attr)
          obj.dsl_parser.send(attr)
        end
      end
    end

    # Extracts the details from a field declaration.
    class DSLFieldParser
      def initialize(hints, symbol, *args, &block)
        @hints  = hints
        @type   = symbol
        @name   = name_from_field_declaration(args)
        @params = params_from_field_declaration(args, &block)
      end

      attr_reader :type, :name, :params

      def name_from_field_declaration(args)
        name, _ = args
        if name == "" || name.is_a?(Hash)
          nil
        else
          name
        end
      end

      def params_from_field_declaration(args, &block)
        params = params_from_args(args)

        if block_given?
          params.merge(params_from_block(&block))
        else
          params
        end
      end

      def params_from_args(args)
        name, params = args
        params = name if name.is_a?(Hash)

        params || {}
      end

      def params_from_block(&block)
        bindata_classes = {
          array:      BinData::Array,
          buffer:     BinData::Buffer,
          choice:     BinData::Choice,
          delayed_io: BinData::DelayedIO,
          section:    BinData::Section,
          skip:       BinData::Skip,
          struct:     BinData::Struct
        }

        if bindata_classes.include?(@type)
          parser = DSLParser.new(bindata_classes[@type], @type)
          parser.endian(@hints[:endian])
          parser.search_prefix(*@hints[:search_prefix])
          parser.instance_eval(&block)

          parser.dsl_params
        else
          {}
        end
      end
    end

    # Validates a field defined in a DSLMixin.
    class DSLFieldValidator
      def initialize(the_class, parser)
        @the_class = the_class
        @dsl_parser = parser
      end

      def validate_field(name)
        if must_not_have_a_name_failed?(name)
          raise SyntaxError, "field must not have a name"
        end

        if all_or_none_names_failed?(name)
          raise SyntaxError, "fields must either all have names, or none must have names"
        end

        if must_have_a_name_failed?(name)
          raise SyntaxError, "field must have a name"
        end

        ensure_valid_name(name)
      end

      def ensure_valid_name(name)
        if name && !option?(:fieldnames_are_values)
          if malformed_name?(name)
            raise SyntaxError, "field '#{name}' is an illegal fieldname"
          end

          if duplicate_name?(name)
            raise SyntaxError, "duplicate field '#{name}'"
          end

          if name_shadows_method?(name)
            raise SyntaxError, "field '#{name}' shadows an existing method"
          end

          if name_is_reserved?(name)
            raise SyntaxError, "field '#{name}' is a reserved name"
          end
        end
      end

      def must_not_have_a_name_failed?(name)
        option?(:no_fieldnames) && !name.nil?
      end

      def must_have_a_name_failed?(name)
        option?(:mandatory_fieldnames) && name.nil?
      end

      def all_or_none_names_failed?(name)
        if option?(:all_or_none_fieldnames) && !fields.empty?
          all_names_blank = fields.all_field_names_blank?
          no_names_blank = fields.no_field_names_blank?

          (!name.nil? && all_names_blank) || (name.nil? && no_names_blank)
        else
          false
        end
      end

      def malformed_name?(name)
        !/^[a-z_]\w*$/.match?(name.to_s)
      end

      def duplicate_name?(name)
        fields.field_name?(name)
      end

      def name_shadows_method?(name)
        @the_class.method_defined?(name)
      end

      def name_is_reserved?(name)
        BinData::Struct::RESERVED.include?(name.to_sym)
      end

      def fields
        @dsl_parser.fields
      end

      def option?(opt)
        @dsl_parser.send(:option?, opt)
      end
    end
  end
end