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.
- checksums.yaml +4 -4
- data/bin/scss-lint +1 -4
- data/config/default.yml +5 -4
- data/data/property-sort-orders/recess.txt +149 -0
- data/data/property-sort-orders/smacss.txt +138 -0
- data/lib/scss_lint.rb +1 -0
- data/lib/scss_lint/cli.rb +93 -153
- data/lib/scss_lint/config.rb +16 -13
- data/lib/scss_lint/control_comment_processor.rb +83 -0
- data/lib/scss_lint/engine.rb +21 -5
- data/lib/scss_lint/exceptions.rb +6 -0
- data/lib/scss_lint/linter.rb +6 -2
- data/lib/scss_lint/linter/bang_format.rb +20 -9
- data/lib/scss_lint/linter/duplicate_property.rb +35 -30
- data/lib/scss_lint/linter/empty_line_between_blocks.rb +1 -1
- data/lib/scss_lint/linter/id_selector.rb +10 -0
- data/lib/scss_lint/linter/indentation.rb +2 -1
- data/lib/scss_lint/linter/leading_zero.rb +6 -6
- data/lib/scss_lint/linter/name_format.rb +11 -0
- data/lib/scss_lint/linter/selector_format.rb +0 -4
- data/lib/scss_lint/linter/single_line_per_property.rb +13 -7
- data/lib/scss_lint/linter/single_line_per_selector.rb +19 -11
- data/lib/scss_lint/linter/trailing_semicolon.rb +5 -3
- data/lib/scss_lint/linter/trailing_zero.rb +4 -4
- data/lib/scss_lint/options.rb +113 -0
- data/lib/scss_lint/reporter/default_reporter.rb +15 -7
- data/lib/scss_lint/reporter/json_reporter.rb +15 -8
- data/lib/scss_lint/reporter/xml_reporter.rb +12 -6
- data/lib/scss_lint/runner.rb +4 -5
- data/lib/scss_lint/version.rb +1 -1
- data/spec/scss_lint/cli_spec.rb +9 -229
- data/spec/scss_lint/linter/bang_format_spec.rb +20 -0
- data/spec/scss_lint/linter/duplicate_property_spec.rb +13 -0
- data/spec/scss_lint/linter/empty_line_between_blocks_spec.rb +12 -11
- data/spec/scss_lint/linter/id_selector_spec.rb +62 -0
- data/spec/scss_lint/linter/indentation_spec.rb +11 -0
- data/spec/scss_lint/linter/name_format_spec.rb +147 -117
- data/spec/scss_lint/linter/selector_format_spec.rb +3 -66
- data/spec/scss_lint/linter/trailing_semicolon_spec.rb +20 -0
- data/spec/scss_lint/linter_spec.rb +248 -0
- data/spec/scss_lint/options_spec.rb +42 -0
- data/spec/spec_helper.rb +1 -1
- metadata +177 -183
- data/lib/scss_lint/linter/id_with_extraneous_selector.rb +0 -20
- data/spec/scss_lint/linter/id_with_extraneous_selector_spec.rb +0 -139
data/lib/scss_lint/config.rb
CHANGED
@@ -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,
|
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
|
-
|
130
|
-
|
131
|
-
next unless name.match(class_name_regex)
|
127
|
+
options
|
128
|
+
end
|
132
129
|
|
133
|
-
|
134
|
-
|
135
|
-
|
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
|
-
|
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
|
data/lib/scss_lint/engine.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
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
|
data/lib/scss_lint/exceptions.rb
CHANGED
@@ -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
|
data/lib/scss_lint/linter.rb
CHANGED
@@ -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
|
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
|
-
|
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
|
-
|
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
|
7
|
-
|
8
|
-
|
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
|
-
|
11
|
-
|
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*\}
|
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
|
@@ -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,
|
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[
|
12
|
-
|
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)[
|
18
|
+
source_from_range(node.source_range)[NUMBER_WITH_LEADING_ZERO_REGEX, 1]
|
19
19
|
|
20
|
-
|
20
|
+
check_for_leading_zeros(node, number)
|
21
21
|
end
|
22
22
|
|
23
23
|
private
|
24
24
|
|
25
|
-
|
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
|
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('%', ''))
|