scss-lint 0.30.0 → 0.31.0

Sign up to get free protection for your applications and to get access to all the features.
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('%', ''))