hashie/hashie

View on GitHub
lib/hashie/rash.rb

Summary

Maintainability
A
3 hrs
Test Coverage
module Hashie
  #
  # Rash is a Hash whose keys can be Regexps, or Ranges, which will
  # match many input keys.
  #
  # A good use case for this class is routing URLs in a web framework.
  # The Rash's keys match URL patterns, and the values specify actions
  # which can handle the URL. When the Rash's value is proc, the proc
  # will be automatically called with the regexp's matched groups as
  # block arguments.
  #
  # Usage example:
  #
  #     greeting = Hashie::Rash.new( /^Mr./ => "Hello sir!", /^Mrs./ => "Evening, madame." )
  #     greeting["Mr. Steve Austin"] #=> "Hello sir!"
  #     greeting["Mrs. Steve Austin"] #=> "Evening, madame."
  #
  # Note: The Rash is automatically optimized every 500 accesses
  #       (Regexps get sorted by how often they get matched).
  #       If this is too low or too high, you can tune it by
  #       setting: `rash.optimize_every = n`
  #
  class Rash
    attr_accessor :optimize_every

    def initialize(initial = {})
      @hash           = {}
      @regexes        = []
      @ranges         = []
      @regex_counts   = Hash.new(0)
      @optimize_every = 500
      @lookups        = 0

      update(initial)
    end

    def update(other)
      other.each do |key, value|
        self[key] = value
      end

      self
    end

    def []=(key, value)
      case key
      when Regexp
        # key = normalize_regex(key)  # this used to just do: /#{regexp}/
        @regexes << key
      when Range
        @ranges << key
      end
      @hash[key] = value
    end

    #
    # Return the first thing that matches the key.
    #
    def [](key)
      all(key).first
    end

    #
    # Raise (or yield) unless something matches the key.
    #
    def fetch(*args)
      raise ArgumentError, "Expected 1-2 arguments, got #{args.length}" \
        unless (1..2).cover?(args.length)

      key, default = args

      all(key) do |value|
        return value
      end

      if block_given?
        yield key
      elsif default
        default
      else
        raise KeyError, "key not found: #{key.inspect}"
      end
    end

    #
    # Return everything that matches the query.
    #
    def all(query)
      return to_enum(:all, query) unless block_given?

      if @hash.include? query
        yield @hash[query]
        return
      end

      case query
      when String
        optimize_if_necessary!

        # see if any of the regexps match the string
        @regexes.each do |regex|
          match = regex.match(query)
          next unless match
          @regex_counts[regex] += 1
          value = @hash[regex]
          if value.respond_to? :call
            yield value.call(match)
          else
            yield value
          end
        end

      when Numeric
        # see if any of the ranges match the integer
        @ranges.each do |range|
          yield @hash[range] if range.cover? query
        end

      when Regexp
        # Reverse operation: `rash[/regexp/]` returns all string keys matching the regexp
        @hash.each do |key, val|
          yield val if key.is_a?(String) && query =~ key
        end
      end
    end

    def method_missing(*args, &block)
      @hash.send(*args, &block) || super
    end

    def respond_to_missing?(method_name, _include_private = false)
      @hash.respond_to?(method_name)
    end

    private

    def optimize_if_necessary!
      return unless (@lookups += 1) >= @optimize_every
      @regexes = @regexes.sort_by { |regex| -@regex_counts[regex] }
      @lookups = 0
    end
  end
end