scss-lint 0.30.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/bin/scss-lint +1 -4
  3. data/config/default.yml +5 -4
  4. data/data/property-sort-orders/recess.txt +149 -0
  5. data/data/property-sort-orders/smacss.txt +138 -0
  6. data/lib/scss_lint.rb +1 -0
  7. data/lib/scss_lint/cli.rb +93 -153
  8. data/lib/scss_lint/config.rb +16 -13
  9. data/lib/scss_lint/control_comment_processor.rb +83 -0
  10. data/lib/scss_lint/engine.rb +21 -5
  11. data/lib/scss_lint/exceptions.rb +6 -0
  12. data/lib/scss_lint/linter.rb +6 -2
  13. data/lib/scss_lint/linter/bang_format.rb +20 -9
  14. data/lib/scss_lint/linter/duplicate_property.rb +35 -30
  15. data/lib/scss_lint/linter/empty_line_between_blocks.rb +1 -1
  16. data/lib/scss_lint/linter/id_selector.rb +10 -0
  17. data/lib/scss_lint/linter/indentation.rb +2 -1
  18. data/lib/scss_lint/linter/leading_zero.rb +6 -6
  19. data/lib/scss_lint/linter/name_format.rb +11 -0
  20. data/lib/scss_lint/linter/selector_format.rb +0 -4
  21. data/lib/scss_lint/linter/single_line_per_property.rb +13 -7
  22. data/lib/scss_lint/linter/single_line_per_selector.rb +19 -11
  23. data/lib/scss_lint/linter/trailing_semicolon.rb +5 -3
  24. data/lib/scss_lint/linter/trailing_zero.rb +4 -4
  25. data/lib/scss_lint/options.rb +113 -0
  26. data/lib/scss_lint/reporter/default_reporter.rb +15 -7
  27. data/lib/scss_lint/reporter/json_reporter.rb +15 -8
  28. data/lib/scss_lint/reporter/xml_reporter.rb +12 -6
  29. data/lib/scss_lint/runner.rb +4 -5
  30. data/lib/scss_lint/version.rb +1 -1
  31. data/spec/scss_lint/cli_spec.rb +9 -229
  32. data/spec/scss_lint/linter/bang_format_spec.rb +20 -0
  33. data/spec/scss_lint/linter/duplicate_property_spec.rb +13 -0
  34. data/spec/scss_lint/linter/empty_line_between_blocks_spec.rb +12 -11
  35. data/spec/scss_lint/linter/id_selector_spec.rb +62 -0
  36. data/spec/scss_lint/linter/indentation_spec.rb +11 -0
  37. data/spec/scss_lint/linter/name_format_spec.rb +147 -117
  38. data/spec/scss_lint/linter/selector_format_spec.rb +3 -66
  39. data/spec/scss_lint/linter/trailing_semicolon_spec.rb +20 -0
  40. data/spec/scss_lint/linter_spec.rb +248 -0
  41. data/spec/scss_lint/options_spec.rb +42 -0
  42. data/spec/spec_helper.rb +1 -1
  43. metadata +177 -183
  44. data/lib/scss_lint/linter/id_with_extraneous_selector.rb +0 -20
  45. data/spec/scss_lint/linter/id_with_extraneous_selector_spec.rb +0 -139
@@ -2,9 +2,6 @@ require 'pathname'
2
2
  require 'yaml'
3
3
 
4
4
  module SCSSLint
5
- # Raised when the configuration file is invalid for some reason.
6
- class InvalidConfiguration < StandardError; end
7
-
8
5
  # Loads and manages application configuration.
9
6
  class Config
10
7
  FILE_NAME = '.scss-lint.yml'
@@ -72,7 +69,8 @@ module SCSSLint
72
69
  {}
73
70
  end
74
71
  rescue => ex
75
- raise InvalidConfiguration, "Invalid configuration: #{ex.message}"
72
+ raise SCSSLint::Exceptions::InvalidConfiguration,
73
+ "Invalid configuration: #{ex.message}"
76
74
  end
77
75
 
78
76
  options = convert_single_options_to_arrays(options)
@@ -122,20 +120,25 @@ module SCSSLint
122
120
  options.fetch('linters', {}).keys.each do |class_name|
123
121
  next unless class_name.include?('*')
124
122
 
125
- class_name_regex = /#{class_name.gsub('*', '[^:]+')}/
126
-
127
123
  wildcard_options = options['linters'].delete(class_name)
124
+ apply_options_to_matching_linters(class_name, options, wildcard_options)
125
+ end
128
126
 
129
- LinterRegistry.linters.each do |linter_class|
130
- name = linter_name(linter_class)
131
- next unless name.match(class_name_regex)
127
+ options
128
+ end
132
129
 
133
- old_options = options['linters'].fetch(name, {})
134
- options['linters'][name] = smart_merge(old_options, wildcard_options)
135
- end
130
+ def apply_options_to_matching_linters(class_name_glob, current_options, linter_options)
131
+ linter_names_matching_glob(class_name_glob).each do |linter_name|
132
+ old_options = current_options['linters'].fetch(linter_name, {})
133
+ current_options['linters'][linter_name] = smart_merge(old_options, linter_options)
136
134
  end
135
+ end
137
136
 
138
- options
137
+ def linter_names_matching_glob(class_name_glob)
138
+ class_name_regex = /#{class_name_glob.gsub('*', '[^:]+')}/
139
+
140
+ LinterRegistry.linters.map { |linter_class| linter_name(linter_class) }
141
+ .select { |linter_name| linter_name.match(class_name_regex) }
139
142
  end
140
143
 
141
144
  def ensure_linter_exclude_paths_are_absolute(options, original_file)
@@ -0,0 +1,83 @@
1
+ require 'set'
2
+
3
+ module SCSSLint
4
+ # Tracks which lines have been disabled for a given linter.
5
+ class ControlCommentProcessor
6
+ def initialize(linter)
7
+ @disable_stack = []
8
+ @disabled_lines = Set.new
9
+ @linter = linter
10
+ end
11
+
12
+ # Filter lints given the comments that were processed in the document.
13
+ #
14
+ # @param lints [Array<SCSSLint::Lint>]
15
+ def filter_lints(lints)
16
+ lints.reject { |lint| @disabled_lines.include?(lint.location.line) }
17
+ end
18
+
19
+ # Executed before a node has been visited.
20
+ #
21
+ # @param node [Sass::Tree::Node]
22
+ def before_node_visit(node)
23
+ return unless node.is_a?(Sass::Tree::CommentNode)
24
+
25
+ return unless match = /(?x)
26
+ \*\s* # Comment line start marker
27
+ scss-lint:
28
+ (?<command>disable|enable)\s+
29
+ (?<linters>.*?)
30
+ \s*(?:\*\/|\n) # Comment end marker or end of line
31
+ /.match(node.value.first)
32
+
33
+ linters = match[:linters].split(/\s*,\s*|\s+/)
34
+ return unless linters.include?('all') || linters.include?(@linter.name)
35
+
36
+ process_command(match[:command], node)
37
+
38
+ # Is the control comment the only thing on this line?
39
+ return if %r{^\s*(//|/\*)}.match(@linter.engine.lines[node.line - 1])
40
+
41
+ # If so, pop since we only want the comment to apply to the single line
42
+ pop_control_comment_stack(node)
43
+ end
44
+
45
+ # Executed after a node has been visited.
46
+ #
47
+ # @param node [Sass::Tree::Node]
48
+ def after_node_visit(node)
49
+ while @disable_stack.any? && @disable_stack.last.node_parent == node
50
+ pop_control_comment_stack(node)
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def process_command(command, node)
57
+ case command
58
+ when 'disable'
59
+ @disable_stack << node
60
+ when 'enable'
61
+ pop_control_comment_stack(node)
62
+ end
63
+ end
64
+
65
+ def pop_control_comment_stack(node)
66
+ return unless comment_node = @disable_stack.pop
67
+
68
+ start_line = comment_node.line
69
+
70
+ # Find the deepest child that has a line number to which a lint might
71
+ # apply (if it is a control comment enable node, it will be the line of
72
+ # the comment itself).
73
+ child = node
74
+ while child.children.last.is_a?(Sass::Tree::Node)
75
+ child = child.children.last
76
+ end
77
+
78
+ end_line = child.line
79
+
80
+ @disabled_lines.merge(start_line..end_line)
81
+ end
82
+ end
83
+ end
@@ -10,14 +10,15 @@ module SCSSLint
10
10
 
11
11
  attr_reader :contents, :filename, :lines, :tree
12
12
 
13
+ # Creates a parsed representation of an SCSS document from the given string
14
+ # or file.
15
+ #
16
+ # @param scss_or_filename [String]
13
17
  def initialize(scss_or_filename)
14
18
  if File.exist?(scss_or_filename)
15
- @filename = scss_or_filename
16
- @engine = Sass::Engine.for_file(scss_or_filename, ENGINE_OPTIONS)
17
- @contents = File.open(scss_or_filename, 'r').read
19
+ build_from_file(scss_or_filename)
18
20
  else
19
- @engine = Sass::Engine.new(scss_or_filename, ENGINE_OPTIONS)
20
- @contents = scss_or_filename
21
+ build_from_string(scss_or_filename)
21
22
  end
22
23
 
23
24
  # Need to force encoding to avoid Windows-related bugs.
@@ -34,5 +35,20 @@ module SCSSLint
34
35
  raise
35
36
  end
36
37
  end
38
+
39
+ private
40
+
41
+ # @param path [String]
42
+ def build_from_file(path)
43
+ @filename = path
44
+ @engine = Sass::Engine.for_file(path, ENGINE_OPTIONS)
45
+ @contents = File.open(path, 'r').read
46
+ end
47
+
48
+ # @param scss [String]
49
+ def build_from_string(scss)
50
+ @engine = Sass::Engine.new(scss, ENGINE_OPTIONS)
51
+ @contents = scss
52
+ end
37
53
  end
38
54
  end
@@ -1,4 +1,10 @@
1
1
  module SCSSLint::Exceptions
2
+ # Raised when an invalid flag is given via the command line.
3
+ class InvalidCLIOption < StandardError; end
4
+
5
+ # Raised when the configuration file is invalid for some reason.
6
+ class InvalidConfiguration < StandardError; end
7
+
2
8
  # Raised when an unexpected error occurs in a linter
3
9
  class LinterError < StandardError; end
4
10
  end
@@ -17,7 +17,9 @@ module SCSSLint
17
17
  def run(engine, config)
18
18
  @config = config
19
19
  @engine = engine
20
+ @comment_processor = ControlCommentProcessor.new(self)
20
21
  visit(engine.tree)
22
+ @lints = @comment_processor.filter_lints(@lints)
21
23
  end
22
24
 
23
25
  # Return the human-friendly name of this linter as specified in the
@@ -44,7 +46,7 @@ module SCSSLint
44
46
  #
45
47
  # @param range [Sass::Source::Range]
46
48
  # @return [SCSSLint::Location]
47
- def location_from_range(range)
49
+ def location_from_range(range) # rubocop:disable Metrics/AbcSize
48
50
  length = if range.start_pos.line == range.end_pos.line
49
51
  range.end_pos.offset - range.start_pos.offset
50
52
  else
@@ -69,7 +71,7 @@ module SCSSLint
69
71
  #
70
72
  # @param source_range [Sass::Source::Range]
71
73
  # @return [String] the original source code
72
- def source_from_range(source_range)
74
+ def source_from_range(source_range) # rubocop:disable Metrics/AbcSize
73
75
  current_line = source_range.start_pos.line - 1
74
76
  last_line = source_range.end_pos.line - 1
75
77
  start_pos = source_range.start_pos.offset - 1
@@ -121,7 +123,9 @@ module SCSSLint
121
123
  visit_selector(node.parsed_rules)
122
124
  end
123
125
 
126
+ @comment_processor.before_node_visit(node)
124
127
  super
128
+ @comment_processor.after_node_visit(node)
125
129
  end
126
130
 
127
131
  # Redefine so we can set the `node_parent` of each node
@@ -16,25 +16,36 @@ module SCSSLint
16
16
 
17
17
  private
18
18
 
19
+ # Start from the back and move towards the front so that any !important or
20
+ # !default !'s will be found *before* quotation marks. Then we can
21
+ # stop at quotation marks to protect against linting !'s within strings
22
+ # (e.g. `content`)
19
23
  def find_bang_offset(range)
24
+ stopping_characters = ['!', '\'', '"']
20
25
  offset = 0
21
- offset += 1 while character_at(range.start_pos, offset) != '!'
26
+ offset -= 1 until stopping_characters.include?(character_at(range.end_pos, offset))
22
27
  offset
23
28
  end
24
29
 
30
+ def is_before_wrong?(range, offset)
31
+ before_expected = config['space_before_bang'] ? / / : /[^ ]/
32
+ before_actual = character_at(range.end_pos, offset - 1)
33
+ (before_actual =~ before_expected).nil?
34
+ end
35
+
36
+ def is_after_wrong?(range, offset)
37
+ after_expected = config['space_after_bang'] ? / / : /[^ ]/
38
+ after_actual = character_at(range.end_pos, offset + 1)
39
+ (after_actual =~ after_expected).nil?
40
+ end
41
+
25
42
  def check_spacing(node)
26
43
  range = node.value_source_range
27
44
  offset = find_bang_offset(range)
28
45
 
29
- before_expected = config['space_before_bang'] ? / / : /[^ ]/
30
- before_actual = character_at(range.start_pos, offset - 1)
31
- before_is_wrong = (before_actual =~ before_expected).nil?
32
-
33
- after_expected = config['space_after_bang'] ? / / : /[^ ]/
34
- after_actual = character_at(range.start_pos, offset + 1)
35
- after_is_wrong = (after_actual =~ after_expected).nil?
46
+ return if character_at(range.end_pos, offset) != '!'
36
47
 
37
- before_is_wrong || after_is_wrong
48
+ is_before_wrong?(range, offset) || is_after_wrong?(range, offset)
38
49
  end
39
50
  end
40
51
  end
@@ -3,16 +3,46 @@ module SCSSLint
3
3
  class Linter::DuplicateProperty < Linter
4
4
  include LinterRegistry
5
5
 
6
- def visit_rule(node)
7
- check_properties(node)
8
- end
6
+ def check_properties(node)
7
+ static_properties(node).each_with_object({}) do |prop, prop_names|
8
+ prop_key = property_key(prop)
9
9
 
10
- def visit_mixindef(node)
11
- check_properties(node)
10
+ if existing_prop = prop_names[prop_key]
11
+ add_lint(prop, "Property `#{existing_prop.name.join}` already "\
12
+ "defined on line #{existing_prop.line}")
13
+ else
14
+ prop_names[prop_key] = prop
15
+ end
16
+ end
17
+
18
+ yield # Continue linting children
12
19
  end
13
20
 
21
+ alias_method :visit_rule, :check_properties
22
+ alias_method :visit_mixindef, :check_properties
23
+
14
24
  private
15
25
 
26
+ def static_properties(node)
27
+ node.children
28
+ .select { |child| child.is_a?(Sass::Tree::PropNode) }
29
+ .reject { |prop| prop.name.any? { |item| item.is_a?(Sass::Script::Node) } }
30
+ end
31
+
32
+ # Returns a key identifying the bucket this property and value correspond to
33
+ # for purposes of uniqueness.
34
+ def property_key(prop)
35
+ prop_key = prop.name.join
36
+ prop_value = property_value(prop)
37
+
38
+ # Differentiate between values for different vendor prefixes
39
+ prop_value.to_s.scan(/^(-[^-]+-.+)/) do |vendor_keyword|
40
+ prop_key << vendor_keyword.first
41
+ end
42
+
43
+ prop_key
44
+ end
45
+
16
46
  def property_value(prop)
17
47
  case prop.value
18
48
  when Sass::Script::Funcall
@@ -24,30 +54,5 @@ module SCSSLint
24
54
  prop.value.to_s
25
55
  end
26
56
  end
27
-
28
- def check_properties(node)
29
- properties = node.children
30
- .select { |child| child.is_a?(Sass::Tree::PropNode) }
31
- .reject { |prop| prop.name.any? { |item| item.is_a?(Sass::Script::Node) } }
32
-
33
- prop_names = {}
34
-
35
- properties.each do |prop|
36
- name = prop.name.join
37
-
38
- prop_hash = name
39
- prop_value = property_value(prop)
40
-
41
- prop_value.to_s.scan(/^(-[^-]+-.+)/) do |vendor_keyword|
42
- prop_hash << vendor_keyword.first
43
- end
44
-
45
- if existing_prop = prop_names[prop_hash]
46
- add_lint(prop, "Property `#{name}` already defined on line #{existing_prop.line}")
47
- else
48
- prop_names[prop_hash] = prop
49
- end
50
- end
51
- end
52
57
  end
53
58
  end
@@ -42,7 +42,7 @@ module SCSSLint
42
42
  # Special case: ignore comments immediately after a closing brace
43
43
  line = engine.lines[next_start_line - 1].strip
44
44
  return if following_node.is_a?(Sass::Tree::CommentNode) &&
45
- line =~ %r{\s*\}\s*/(/|\*)}
45
+ line =~ %r{\s*\}?\s*/(/|\*)}
46
46
 
47
47
  # Otherwise check if line before the next node's starting line is blank
48
48
  line = engine.lines[next_start_line - 2].strip
@@ -0,0 +1,10 @@
1
+ module SCSSLint
2
+ # Checks for the use of an ID selector.
3
+ class Linter::IdSelector < Linter
4
+ include LinterRegistry
5
+
6
+ def visit_id(id)
7
+ add_lint(id, 'Avoid using id selectors')
8
+ end
9
+ end
10
+ end
@@ -126,8 +126,9 @@ module SCSSLint
126
126
 
127
127
  def at_root_contains_inline_selector?(node)
128
128
  return unless node.children.any?
129
+ return unless first_child_source = node.children.first.source_range
129
130
 
130
- same_position?(node.source_range.end_pos, node.children.first.source_range.start_pos)
131
+ same_position?(node.source_range.end_pos, first_child_source.start_pos)
131
132
  end
132
133
  end
133
134
  end
@@ -8,21 +8,21 @@ module SCSSLint
8
8
 
9
9
  non_string_values = remove_quoted_strings(node.value).split
10
10
  non_string_values.each do |value|
11
- next unless number = value[FRACTIONAL_DIGIT_REGEX, 1]
12
- check_number(node, number)
11
+ next unless number = value[NUMBER_WITH_LEADING_ZERO_REGEX, 1]
12
+ check_for_leading_zeros(node, number)
13
13
  end
14
14
  end
15
15
 
16
16
  def visit_script_number(node)
17
17
  return unless number =
18
- source_from_range(node.source_range)[FRACTIONAL_DIGIT_REGEX, 1]
18
+ source_from_range(node.source_range)[NUMBER_WITH_LEADING_ZERO_REGEX, 1]
19
19
 
20
- check_number(node, number)
20
+ check_for_leading_zeros(node, number)
21
21
  end
22
22
 
23
23
  private
24
24
 
25
- FRACTIONAL_DIGIT_REGEX = /^-?(0?\.\d+)/
25
+ NUMBER_WITH_LEADING_ZERO_REGEX = /^-?(0?\.\d+)/
26
26
 
27
27
  CONVENTIONS = {
28
28
  'exclude_zero' => {
@@ -37,7 +37,7 @@ module SCSSLint
37
37
  },
38
38
  }
39
39
 
40
- def check_number(node, original_number)
40
+ def check_for_leading_zeros(node, original_number)
41
41
  style = config.fetch('style', 'exclude_zero')
42
42
  convention = CONVENTIONS[style]
43
43
  return if convention[:validator].call(original_number)
@@ -46,12 +46,23 @@ module SCSSLint
46
46
  ].to_set
47
47
 
48
48
  def check_name(node, node_type, node_text = node.name)
49
+ node_text = trim_underscore_prefix(node_text)
49
50
  return unless violation = violated_convention(node_text)
50
51
 
51
52
  add_lint(node, "Name of #{node_type} `#{node_text}` should be " \
52
53
  "written #{violation[:explanation]}")
53
54
  end
54
55
 
56
+ # Removes underscore prefix from name if leading underscores are allowed.
57
+ def trim_underscore_prefix(name)
58
+ if config['allow_leading_underscore']
59
+ # Remove if there is a single leading underscore
60
+ name = name.gsub(/^_(?!_)/, '')
61
+ end
62
+
63
+ name
64
+ end
65
+
55
66
  def check_placeholder(node)
56
67
  extract_string_selectors(node.selector).any? do |selector_str|
57
68
  check_name(node, 'placeholder', selector_str.gsub('%', ''))