chriseppstein/compass

View on GitHub
core/lib/compass/core/caniuse.rb

Summary

Maintainability
C
7 hrs
Test Coverage
require 'multi_json'
require 'singleton'
class Compass::Core::CanIUse
  include Singleton

  DATA_FILE_NAME = File.join(Compass::Core.base_directory, "data", "caniuse.json")
  DATA_FEATURE_FILES = Dir.glob(File.join(Compass::Core.base_directory, "data", "caniuse_extras", "**", "*.json"))

  def initialize
    @data = MultiJson.load(File.read(DATA_FILE_NAME))
    # support ad-hoc features
    DATA_FEATURE_FILES.each do |feature_file|
      feature_name = File.basename(feature_file, ".json")
      # if the feature doesn't exist in the master `caniuse.json`
      if @data["data"][feature_name].nil?
        @data["data"][feature_name] = MultiJson.load(File.read(feature_file))
      end
    end
  end

  # The browser names from caniuse are ugly.
  PUBLIC_BROWSER_NAMES = Hash.new {|h, k| k}
  PUBLIC_BROWSER_NAMES.update(
    "and_chr" => "android-chrome",
    "and_ff"  => "android-firefox",
    "android" => "android",
    "bb"      => "blackberry",
    "chrome"  => "chrome",
    "firefox" => "firefox",
    "ie"      => "ie",
    "ie_mob"  => "ie-mobile",
    "ios_saf" => "ios-safari",
    "op_mini" => "opera-mini",
    "op_mob"  => "opera-mobile",
    "opera"   => "opera",
    "safari"  => "safari"
  )
  CAN_I_USE_NAMES = Hash.new {|h, k| k}
  CAN_I_USE_NAMES.update(PUBLIC_BROWSER_NAMES.invert)

  # Returns all the known browsers according to caniuse
  def browsers
    @browsers ||= @data["agents"].keys.map{|b| PUBLIC_BROWSER_NAMES[b] }.sort
  end

  # Returns the prefix corresponding to a particular browser
  def prefix(browser, version = nil)
    version = caniuse_version(browser, version)
    assert_valid_browser browser
    assert_valid_version browser, version if version
    data = browser_data(browser)
    p = if data["prefix_exceptions"] && data["prefix_exceptions"][version]
          data["prefix_exceptions"][version]
        else
          data["prefix"]
        end
    "-#{p}"
  end

  # returns all possible prefixes a browser might use.
  def all_prefixes(browser)
    assert_valid_browser browser
    data = browser_data(browser)
    prefixes = ["-#{data["prefix"]}"]
    if data["prefix_exceptions"]
      prefixes += data["prefix_exceptions"].values.uniq.map{|p| "-#{p}"}
    end
    prefixes
  end

  # returns the prefixes needed by the list of browsers given
  def prefixes(browsers = browsers)
    result = browsers.map{|b| all_prefixes(b) }
    result.flatten!
    result.uniq!
    result.sort!
    result
  end

  def browser_ranges(capability, prefix = nil, include_unprefixed_versions = true)
    assert_valid_capability capability
    browsers = prefix.nil? ? browsers() : browsers_with_prefix(prefix)
    browsers.inject({}) do |m, browser|
      browser_versions = versions(browser)
      min_version = find_first_prefixed_version(browser, browser_versions, capability, prefix)
      if min_version
        max_version = if include_unprefixed_versions
                        browser_versions.last
                      else
                        find_first_prefixed_version(browser, browser_versions.reverse, capability, prefix)
                      end
        m.update(browser => [min_version, max_version])
      end
      m
    end
  end

  def find_first_prefixed_version(browser, versions, capability, prefix)
    versions.find do |version|
      support = browser_support(browser, version, capability)
      if prefix.nil?
        support !~ /\b(n|p)\b/ && support !~ /\bx\b/
      else
        actual_prefix = prefix(browser, version)
        support !~ /\b(n|p)\b/ && support =~ /\bx\b/ && prefix == actual_prefix
      end
    end
  end

  # @overload omitted_usage(browser, min_supported_version)
  #   How many users would be omitted if support for the given browser starts
  #   with the given version.
  #
  # @overload omitted_usage(browser, min_unsupported_version, max_unsupported_version)
  #   How many users would be omitted if the browsers with version
  def omitted_usage(browser, min_version, max_version = nil)
    versions = versions(browser)
    min_version = caniuse_version(browser, min_version)
    max_version = caniuse_version(browser, max_version)
    if max_version.nil?
      assert_valid_version browser, min_version
    else
      assert_valid_version browser, min_version, max_version
    end
    usage = 0
    in_range = max_version.nil?
    versions.each do |version|
      break if max_version.nil? && version == min_version
      in_range = true if (!max_version.nil? && version == min_version)
      usage += usage(browser, version) if in_range
      break if !max_version.nil? && version == max_version
    end
    return usage
  end

  # returns the list of browsers that use the given prefix
  def browsers_with_prefix(prefix)
    assert_valid_prefix prefix
    prefix = "-" + prefix unless prefix.start_with?("-")
    browsers.select {|b| all_prefixes(b).include?(prefix) }
  end

  SPEC_VERSION_MATCHERS = Hash.new do |h, k|
    h[k] = /##{k}\b/
  end

  CAPABILITY_MATCHERS = {
    :full_support => lambda {|support, capability| !support ^ (capability =~ /\by\b/) },
    :partial_support => lambda {|support, capability| !support ^ (capability =~ /\ba\b/) },
    :prefixed => lambda {|support, capability| !support ^ (capability =~ /\bx\b/) },
    :spec_versions => lambda {|versions, capability| versions.any? {|v| capability =~ SPEC_VERSION_MATCHERS[v] } }
  }

  # Return whether the capability matcher the options specified.
  # For each capability option in the options the capability will need to match it.
  def capability_matches(support, capability_options_list)
    capability_options_list.any? do |capability_options|
      capability_options.all? {|c, v| CAPABILITY_MATCHERS[c].call(v, support)}
    end
  end

  # returns the percentage of users (0-100) that would be affected if the prefix
  # was not used with the given capability.
  def prefixed_usage(prefix, capability, capability_options_list)
    assert_valid_prefix prefix
    assert_valid_capability capability
    usage = 0
    browsers_with_prefix(prefix).each do |browser|
      versions(browser).each do |version|
        next unless prefix == prefix(browser, version)
        support = browser_support(browser, version, capability)
        if capability_matches(support, capability_options_list) and support =~ /\bx\b/
          usage += usage(browser, version)
        end
      end
    end
    usage
  end

  def next_version(browser, version)
    version = caniuse_version(browser, version)
    versions = versions(browser)
    index = versions.index(version)
    index < versions.length - 1 ? versions[index + 1] : nil
  end

  def previous_version(browser, version)
    version = caniuse_version(browser, version)
    versions = versions(browser)
    index = versions.index(version)
    index > 0 ? versions[index - 1] : nil
  end

  # Returns whether the given minimum version of a browser
  # requires the use of a prefix for the stated capability.
  def requires_prefix(browser, min_version, capability, capability_options_list)
    min_version = caniuse_version(browser, min_version)
    assert_valid_browser browser
    assert_valid_capability capability
    found_version = false
    versions(browser).each do |version|
      found_version ||= version == min_version
      next unless found_version
      support = browser_support(browser, version, capability)
      if capability_matches(support, capability_options_list) and support =~ /\bx\b/
        return prefix(browser, version)
      end
    end
    raise ArgumentError, "#{min_version} is not a version for #{browser}" unless found_version
    nil
  end

  # These are versions that users might reasonably type
  # mapped to the caniuse version.
  ALTERNATE_VERSIONS = {
    "android" => {
      "4.2" => "4.2-4.3",
      "4.3" => "4.2-4.3"
    },
    "opera" => {
      "9.5" => "9.5-9.6",
      "9.6" => "9.5-9.6",
      "10.0" => "10.0-10.1",
      "10.1" => "10.0-10.1",
    },
    "opera-mobile" => {
      "14" => "0"
    }
  }

  # Returns the versions of a browser. If the min_usage parameter is provided,
  # only those versions having met the threshold of user percentage.
  #
  # @param min_usage a decimal number betwee 0 and 100
  def versions(browser, min_usage = 0)
    assert_valid_browser browser
    versions = browser_data(browser)["versions"].compact
    return versions if min_usage == 0
    versions.select {|v| browser_data(browser)["usage_global"][v] > min_usage }
  end

  # The list of capabilities tracked by caniuse.
  def capabilities
    @capabilities ||= @data["data"].keys.select do |cap|
      cats = @data["data"][cap]["categories"]
      cats.any?{|cat| cat =~ /CSS/ }
    end.sort
  end

  # the usage % for a given browser version.
  def usage(browser, version)
    browser_data(browser)["usage_global"][version]
  end

  # returns a valid version given the version provided by the user
  # This is used to maintain API compatibility when caniuse removes
  # a version from their data (which seems to be replaced with a semantic equivalent).
  def caniuse_version(browser, version)
    return unless version
    ALTERNATE_VERSIONS[browser] && ALTERNATE_VERSIONS[browser][version] || version
  end

  def inspect
    "#{self.class.name}(#{browsers.join(", ")})"
  end

  # the browser data assocated with a given capability
  def capability_data(capability)
    @data["data"][capability]
  end

  def browser_support(browser, version, capability)
    version = caniuse_version(browser, version)
    capability_data(capability)["stats"][CAN_I_USE_NAMES[browser]][version]
  end

  # the metadata assocated with a given browser
  def browser_data(browser)
    @data["agents"][CAN_I_USE_NAMES[browser]]
  end

  # efficiently checks if a prefix is valid
  def assert_valid_prefix(prefix)
    @known_prefixes ||= Set.new(prefixes(browsers))
    unless @known_prefixes.include?(prefix)
      raise ArgumentError, "#{prefix} is not known browser prefix."
    end
  end

  # efficiently checks if a browser is valid
  def assert_valid_browser(browser)
    @known_browsers ||= Set.new(browsers)
    unless @known_browsers.include?(browser)
      raise ArgumentError, "#{browser} is not known browser."
    end
  end

  # efficiently checks if a capability is valid
  def assert_valid_capability(capability)
    @known_capabilities ||= Set.new(capabilities)
    unless @known_capabilities.include?(capability)
      raise ArgumentError, "#{capability} is not known browser capability."
    end
    nil
  end

  def assert_valid_version(browser, *versions)
    versions.each do |v|
      unless versions(browser).include?(v)
        raise ArgumentError, "#{v} is not known version for #{browser}."
      end
    end
  end
end