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.
- 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('%', ''))
|