sds/scss-lint

View on GitHub
lib/scss_lint/linter/property_sort_order.rb

Summary

Maintainability
A
3 hrs
Test Coverage
module SCSSLint
  # Checks the declaration order of properties.
  class Linter::PropertySortOrder < Linter # rubocop:disable ClassLength
    include LinterRegistry

    def visit_root(_node)
      @preferred_order = extract_preferred_order_from_config

      if @preferred_order && config['separate_groups']
        @group = assign_groups(@preferred_order)
      end

      yield # Continue linting children
    end

    def check_order(node)
      sortable_props = node.children.select do |child|
        child.is_a?(Sass::Tree::PropNode) && !ignore_property?(child)
      end

      if sortable_props.count >= config.fetch('min_properties', 2)
        sortable_prop_info =
          sortable_props.map do |child|
            name = child.name.join
            /^(?<vendor>-\w+(-osx)?-)?(?<property>.+)/ =~ name
            { name: name, vendor: vendor, property: "#{@nested_under}#{property}", node: child }
          end

        check_sort_order(sortable_prop_info)
        check_group_separation(sortable_prop_info) if @group
      end

      yield # Continue linting children
    end

    alias visit_media check_order
    alias visit_mixin check_order
    alias visit_rule check_order

    def visit_prop(node, &block)
      # Handle nested properties by appending the parent property they are
      # nested under to the name
      @nested_under = "#{node.name.join}-"
      check_order(node, &block)
      @nested_under = nil
    end

    def visit_if(node, &block)
      check_order(node, &block)
      visit(node.else) if node.else
    end

  private

    # When enforcing whether a blank line should separate "groups" of
    # properties, we need to assign those properties to group numbers so we can
    # quickly tell traversing from one property to the other that a blank line
    # is required (since the group number would change).
    def assign_groups(order)
      group_number = 0
      last_was_empty = false

      order.each_with_object({}) do |property, group|
        # A gap indicates the start of the next group
        if property.nil? || property.strip.empty?
          group_number += 1 unless last_was_empty # Treat multiple gaps as single gap
          last_was_empty = true
          next
        end

        last_was_empty = false

        group[property] = group_number
      end
    end

    def check_sort_order(sortable_prop_info)
      sortable_prop_info = sortable_prop_info.uniq { |item| item[:name] }
      sorted_props = sortable_prop_info.sort { |a, b| compare_properties(a, b) }

      sorted_props.each_with_index do |prop, index|
        # Once we reach the portion of the list with unspecified properties, we
        # can stop checking since we don't care about order after that point
        break unless specified_property?(prop[:property])

        next unless prop != sortable_prop_info[index]

        add_lint(sortable_prop_info[index][:node], lint_message(sorted_props))
        break
      end
    end

    def check_group_separation(sortable_prop_info) # rubocop:disable AbcSize
      group_number = @group[sortable_prop_info.first[:property]]

      sortable_prop_info[0..-2].zip(sortable_prop_info[1..-1]).each do |first, second|
        next unless @group[second[:property]] != group_number

        # We're now in the next group
        group_number = @group[second[:property]]

        # The group number has changed, so ensure this property is separated
        # from the previous property by at least a line (could be a comment,
        # we don't care, but at least one line that isn't another property).
        next if first[:node].line < second[:node].line - 1

        add_lint second[:node], "Property `#{second[:name]}` should have an " \
                                'empty line separating it from the previous ' \
                                "group of properties ending with `#{first[:name]}`"
      end
    end

    # Compares two properties which can contain a vendor prefix. It allows for a
    # sort order like:
    #
    #   p {
    #     border: ...
    #     -moz-border-radius: ...
    #     -o-border-radius: ...
    #     -webkit-border-radius: ...
    #     border-radius: ...
    #     color: ...
    #   }
    #
    # ...where vendor-prefixed properties come before the standard property, and
    # are ordered amongst themselves by vendor prefix.
    def compare_properties(a, b)
      if a[:property] == b[:property]
        compare_by_vendor(a, b)
      elsif @preferred_order
        compare_by_order(a, b, @preferred_order)
      else
        a[:property] <=> b[:property]
      end
    end

    def compare_by_vendor(a, b)
      if a[:vendor] && b[:vendor]
        a[:vendor] <=> b[:vendor]
      elsif a[:vendor]
        -1
      elsif b[:vendor]
        1
      else
        0
      end
    end

    def compare_by_order(a, b, order)
      (order.index(a[:property]) || Float::INFINITY) <=>
        (order.index(b[:property]) || Float::INFINITY)
    end

    def extract_preferred_order_from_config
      case config['order']
      when nil
        nil # No custom order specified
      when Array
        config['order']
      when String
        begin
          file = File.open(File.join(SCSS_LINT_DATA,
                                     'property-sort-orders',
                                     "#{config['order']}.txt"))
          file.read.split("\n").reject { |line| line =~ /^\s*#/ }
        rescue Errno::ENOENT
          raise SCSSLint::Exceptions::LinterError,
                "Preset property sort order '#{config['order']}' does not exist"
        end
      else
        raise SCSSLint::Exceptions::LinterError,
              'Invalid property sort order specified -- must be the name of a '\
              'preset or an array of strings'
      end
    end

    # Return whether to ignore a property in the sort order.
    #
    # This includes:
    # - properties containing interpolation
    # - properties not explicitly defined in the sort order (if ignore_unspecified is set)
    def ignore_property?(prop_node)
      return true if prop_node.name.any? { |part| !part.is_a?(String) }

      config['ignore_unspecified'] &&
        @preferred_order &&
        !@preferred_order.include?(prop_node.name.join)
    end

    def specified_property?(prop_name)
      !@preferred_order || @preferred_order.include?(prop_name)
    end

    def preset_order?
      config['order'].is_a?(String)
    end

    def lint_message(sortable_prop_info)
      props = sortable_prop_info.map { |prop| prop[:name] }.join(', ')
      "Properties should be ordered #{props}"
    end
  end
end