haml_lint 0.73.0 → 0.75.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/config/default.yml +10 -5
- data/config/forced_rubocop_config.yml +13 -13
- data/lib/haml_lint/cli.rb +5 -1
- data/lib/haml_lint/document.rb +3 -8
- data/lib/haml_lint/exceptions.rb +2 -2
- data/lib/haml_lint/extensions/haml_util_unescape_interpolation_tracking.rb +2 -2
- data/lib/haml_lint/haml_visitor.rb +1 -1
- data/lib/haml_lint/lint.rb +1 -1
- data/lib/haml_lint/linter/class_attribute_with_static_value.rb +51 -11
- data/lib/haml_lint/linter/classes_before_ids.rb +16 -1
- data/lib/haml_lint/linter/consecutive_comments.rb +20 -1
- data/lib/haml_lint/linter/empty_object_reference.rb +16 -1
- data/lib/haml_lint/linter/empty_script.rb +31 -1
- data/lib/haml_lint/linter/final_newline.rb +31 -7
- data/lib/haml_lint/linter/implicit_div.rb +14 -1
- data/lib/haml_lint/linter/leading_comment_space.rb +13 -1
- data/lib/haml_lint/linter/multiline_script.rb +65 -5
- data/lib/haml_lint/linter/rubocop.rb +10 -22
- data/lib/haml_lint/linter/ruby_comments.rb +14 -4
- data/lib/haml_lint/linter/space_before_script.rb +37 -4
- data/lib/haml_lint/linter/space_inside_hash_attributes.rb +41 -3
- data/lib/haml_lint/linter/tag_name.rb +14 -1
- data/lib/haml_lint/linter/trailing_empty_lines.rb +13 -1
- data/lib/haml_lint/linter/trailing_whitespace.rb +15 -2
- data/lib/haml_lint/linter/unescaped_html.rb +27 -0
- data/lib/haml_lint/linter/unnecessary_interpolation.rb +30 -3
- data/lib/haml_lint/linter/unnecessary_string_output.rb +35 -3
- data/lib/haml_lint/linter.rb +112 -1
- data/lib/haml_lint/node_transformer.rb +2 -2
- data/lib/haml_lint/reporter/disabled_config_reporter.rb +26 -5
- data/lib/haml_lint/reporter/github_reporter.rb +26 -8
- data/lib/haml_lint/ruby_extraction/base_chunk.rb +3 -3
- data/lib/haml_lint/ruby_extraction/chunk_extractor.rb +37 -13
- data/lib/haml_lint/ruby_extraction/coordinator.rb +2 -2
- data/lib/haml_lint/ruby_extraction/implicit_end_chunk.rb +1 -1
- data/lib/haml_lint/ruby_extraction/non_ruby_filter_chunk.rb +1 -1
- data/lib/haml_lint/ruby_extraction/placeholder_marker_chunk.rb +1 -1
- data/lib/haml_lint/ruby_extraction/script_chunk.rb +1 -1
- data/lib/haml_lint/runner.rb +31 -3
- data/lib/haml_lint/source.rb +2 -6
- data/lib/haml_lint/spec/matchers/report_lint.rb +2 -2
- data/lib/haml_lint/tree/comment_node.rb +1 -1
- data/lib/haml_lint/tree/doctype_node.rb +1 -1
- data/lib/haml_lint/tree/filter_node.rb +14 -1
- data/lib/haml_lint/tree/haml_comment_node.rb +2 -2
- data/lib/haml_lint/tree/node.rb +3 -3
- data/lib/haml_lint/tree/root_node.rb +2 -2
- data/lib/haml_lint/tree/silent_script_node.rb +1 -1
- data/lib/haml_lint/tree/tag_node.rb +57 -25
- data/lib/haml_lint/utils.rb +1 -1
- data/lib/haml_lint/version.rb +1 -1
- metadata +3 -2
|
@@ -5,6 +5,9 @@ module HamlLint
|
|
|
5
5
|
class Linter::MultilineScript < Linter
|
|
6
6
|
include LinterRegistry
|
|
7
7
|
|
|
8
|
+
supports_autocorrect(true)
|
|
9
|
+
autocorrect_safe(false)
|
|
10
|
+
|
|
8
11
|
# List of operators that can split a script into two lines that we want to
|
|
9
12
|
# alert on.
|
|
10
13
|
SPLIT_OPERATORS = %w[
|
|
@@ -31,8 +34,40 @@ module HamlLint
|
|
|
31
34
|
check(node)
|
|
32
35
|
end
|
|
33
36
|
|
|
37
|
+
def after_visit_root(node)
|
|
38
|
+
super
|
|
39
|
+
return if merges.empty?
|
|
40
|
+
|
|
41
|
+
apply_autocorrect(merged_source)
|
|
42
|
+
end
|
|
43
|
+
|
|
34
44
|
private
|
|
35
45
|
|
|
46
|
+
def reset_autocorrect_state
|
|
47
|
+
super
|
|
48
|
+
@merges = []
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def merged_source
|
|
52
|
+
lines = document.source_lines.dup
|
|
53
|
+
to_delete = apply_merges(lines)
|
|
54
|
+
lines.reject.with_index { |_, index| to_delete.include?(index) }.join("\n")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Mutates +lines+ in place, folding each continuation script onto its chain
|
|
58
|
+
# root, and returns the line indices that should be deleted.
|
|
59
|
+
#
|
|
60
|
+
# @return [Array<Integer>]
|
|
61
|
+
def apply_merges(lines)
|
|
62
|
+
redirect = {}
|
|
63
|
+
merges.sort_by { |merge| merge[:from] }.map do |merge|
|
|
64
|
+
root = redirect.fetch(merge[:from], merge[:from])
|
|
65
|
+
redirect[merge[:succ_line]] = root
|
|
66
|
+
lines[root] = "#{lines[root].rstrip} #{merge[:succ_script]}"
|
|
67
|
+
merge[:succ_line]
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
36
71
|
def check(node)
|
|
37
72
|
# Condition occurs when scripts do not contain nested content, e.g.
|
|
38
73
|
#
|
|
@@ -48,11 +83,36 @@ module HamlLint
|
|
|
48
83
|
return unless node.children.empty?
|
|
49
84
|
|
|
50
85
|
operator = node.script[/\s+(\S+)\z/, 1]
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
86
|
+
return unless SPLIT_OPERATORS.include?(operator)
|
|
87
|
+
|
|
88
|
+
corrected = collect_merge(node)
|
|
89
|
+
record_lint(node,
|
|
90
|
+
"Script with trailing operator `#{operator}` should be " \
|
|
91
|
+
'merged with the script on the following line',
|
|
92
|
+
corrected: corrected)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def collect_merge(node)
|
|
96
|
+
return false unless autocorrect?
|
|
97
|
+
|
|
98
|
+
# Only merge with the immediately following sibling on the next line.
|
|
99
|
+
# `node.successor` would climb to an ancestor's sibling when this script is
|
|
100
|
+
# the last child of its block, which would pull a line from outside the
|
|
101
|
+
# block into it and change the semantics.
|
|
102
|
+
succ = node.subsequents.first
|
|
103
|
+
return false unless succ && %i[script silent_script].include?(succ.type)
|
|
104
|
+
return false unless succ.line == node.line + 1
|
|
105
|
+
|
|
106
|
+
merges << {
|
|
107
|
+
from: node.line - 1,
|
|
108
|
+
succ_line: succ.line - 1,
|
|
109
|
+
succ_script: succ.script.strip,
|
|
110
|
+
}
|
|
111
|
+
true
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def merges
|
|
115
|
+
@merges ||= []
|
|
56
116
|
end
|
|
57
117
|
end
|
|
58
118
|
end
|
|
@@ -4,14 +4,14 @@ require 'rubocop'
|
|
|
4
4
|
require 'tempfile'
|
|
5
5
|
|
|
6
6
|
module HamlLint
|
|
7
|
-
# Runs RuboCop on the Ruby code contained within
|
|
7
|
+
# Runs RuboCop on the Ruby code contained within Haml templates.
|
|
8
8
|
#
|
|
9
9
|
# The processing is done by extracting a Ruby file that matches the content, including
|
|
10
|
-
# the indentation, of the
|
|
11
|
-
# and get new Ruby code which should be
|
|
10
|
+
# the indentation, of the Haml file. This way, we can run RuboCop with autocorrect
|
|
11
|
+
# and get new Ruby code which should be Haml compatible.
|
|
12
12
|
#
|
|
13
|
-
# The ruby extraction makes "Chunks" which wrap each
|
|
14
|
-
# use the corrected Ruby code to apply the corrections back in the
|
|
13
|
+
# The ruby extraction makes "Chunks" which wrap each Haml constructs. The Chunks can then
|
|
14
|
+
# use the corrected Ruby code to apply the corrections back in the Haml using logic specific
|
|
15
15
|
# to each type of Chunk.
|
|
16
16
|
#
|
|
17
17
|
# The work is spread across the classes in the HamlLint::RubyExtraction module.
|
|
@@ -20,10 +20,12 @@ module HamlLint
|
|
|
20
20
|
class Runner < ::RuboCop::Runner
|
|
21
21
|
attr_reader :offenses
|
|
22
22
|
|
|
23
|
-
def run(haml_path, ruby_code, config
|
|
24
|
-
@allow_cache = allow_cache
|
|
23
|
+
def run(haml_path, ruby_code, config:)
|
|
25
24
|
@offenses = []
|
|
26
25
|
@config_store.instance_variable_set(:@options_config, config)
|
|
26
|
+
# Using stdin also disables RuboCop's result cache, which is intentional:
|
|
27
|
+
# it reconstructs offense positions from the on-disk Haml, not the Ruby we
|
|
28
|
+
# inspected, so reusing it misreports lines (sds/haml-lint#593).
|
|
27
29
|
@options[:stdin] = ruby_code
|
|
28
30
|
super([haml_path])
|
|
29
31
|
end
|
|
@@ -40,20 +42,6 @@ module HamlLint
|
|
|
40
42
|
def file_finished(_file, offenses)
|
|
41
43
|
@offenses = offenses
|
|
42
44
|
end
|
|
43
|
-
|
|
44
|
-
# RuboCop caches results by taking a hash of the file contents & path, among other things.
|
|
45
|
-
# It disables its cache when working on file-content from stdin.
|
|
46
|
-
# Unfortunately we always use RuboCop's stdin, even when we're linting a file on-disk.
|
|
47
|
-
# So, override RuboCop::Runner#cached_run? so that it'll allow caching results, so long
|
|
48
|
-
# as haml-lint itself isn't being invoked with files on stdin.
|
|
49
|
-
def cached_run?
|
|
50
|
-
return false unless @allow_cache
|
|
51
|
-
|
|
52
|
-
@cached_run ||=
|
|
53
|
-
(@options[:cache] == 'true' ||
|
|
54
|
-
(@options[:cache] != 'false' && @config_store.for_pwd.for_all_cops['UseCache'])) &&
|
|
55
|
-
!@options[:auto_gen_config]
|
|
56
|
-
end
|
|
57
45
|
end
|
|
58
46
|
|
|
59
47
|
include LinterRegistry
|
|
@@ -242,7 +230,7 @@ module HamlLint
|
|
|
242
230
|
# @param path [String] the path to tell RuboCop we are running
|
|
243
231
|
# @return [Array<RuboCop::Cop::Offense>, String]
|
|
244
232
|
def run_rubocop(rubocop_runner, ruby_code, path) # rubocop:disable Metrics
|
|
245
|
-
rubocop_runner.run(path, ruby_code, config: rubocop_config_for(path)
|
|
233
|
+
rubocop_runner.run(path, ruby_code, config: rubocop_config_for(path))
|
|
246
234
|
|
|
247
235
|
if ENV['HAML_LINT_INTERNAL_DEBUG'] == 'true'
|
|
248
236
|
if rubocop_runner.offenses.empty?
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module HamlLint
|
|
4
|
-
# Checks for Ruby comments that can be written as
|
|
4
|
+
# Checks for Ruby comments that can be written as Haml comments.
|
|
5
5
|
class Linter::RubyComments < Linter
|
|
6
6
|
include LinterRegistry
|
|
7
7
|
|
|
8
|
+
supports_autocorrect(true)
|
|
9
|
+
|
|
8
10
|
def visit_silent_script(node)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
return unless code_comment?(node)
|
|
12
|
+
|
|
13
|
+
corrected = correct_comment(node)
|
|
14
|
+
record_lint(node, 'Use `-#` for comments instead of `- #`', corrected: corrected)
|
|
12
15
|
end
|
|
13
16
|
|
|
14
17
|
private
|
|
@@ -16,5 +19,12 @@ module HamlLint
|
|
|
16
19
|
def code_comment?(node)
|
|
17
20
|
node.script =~ /\A\s+#/
|
|
18
21
|
end
|
|
22
|
+
|
|
23
|
+
# @return [Boolean]
|
|
24
|
+
def correct_comment(node)
|
|
25
|
+
index = node.line - 1
|
|
26
|
+
line = autocorrected_lines[index]
|
|
27
|
+
correct_line(index, line.sub(/\A(\s*)-\s+#/, '\1-#'))
|
|
28
|
+
end
|
|
19
29
|
end
|
|
20
30
|
end
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module HamlLint
|
|
4
|
-
# Checks for Ruby script in
|
|
4
|
+
# Checks for Ruby script in Haml templates with no space after the `=`/`-`.
|
|
5
5
|
class Linter::SpaceBeforeScript < Linter
|
|
6
6
|
include LinterRegistry
|
|
7
7
|
|
|
8
|
+
supports_autocorrect(true)
|
|
9
|
+
|
|
8
10
|
MESSAGE_FORMAT = 'The %s symbol should have one space separating it from code'
|
|
9
11
|
|
|
10
12
|
ALLOWED_SEPARATORS = [' ', '#'].freeze
|
|
@@ -33,18 +35,23 @@ module HamlLint
|
|
|
33
35
|
# (need to do it this way as the parser strips whitespace from node)
|
|
34
36
|
return unless tag_with_text[index - 1] != ' '
|
|
35
37
|
|
|
36
|
-
|
|
38
|
+
corrected = correct_inline_script(node, text)
|
|
39
|
+
record_lint(node, MESSAGE_FORMAT % '=', corrected: corrected)
|
|
37
40
|
end
|
|
38
41
|
|
|
39
42
|
def visit_script(node)
|
|
40
43
|
# Plain text nodes with interpolation are converted to script nodes, so we
|
|
41
44
|
# need to ignore them here.
|
|
42
45
|
return unless document.source_lines[node.line - 1].lstrip.start_with?('=')
|
|
43
|
-
|
|
46
|
+
return unless missing_space?(node)
|
|
47
|
+
|
|
48
|
+
record_lint(node, MESSAGE_FORMAT % '=', corrected: correct_leading_marker(node, '='))
|
|
44
49
|
end
|
|
45
50
|
|
|
46
51
|
def visit_silent_script(node)
|
|
47
|
-
|
|
52
|
+
return unless missing_space?(node)
|
|
53
|
+
|
|
54
|
+
record_lint(node, MESSAGE_FORMAT % '-', corrected: correct_leading_marker(node, '-'))
|
|
48
55
|
end
|
|
49
56
|
|
|
50
57
|
private
|
|
@@ -53,5 +60,31 @@ module HamlLint
|
|
|
53
60
|
text = node.script
|
|
54
61
|
!ALLOWED_SEPARATORS.include?(text[0]) if text
|
|
55
62
|
end
|
|
63
|
+
|
|
64
|
+
# Inserts one space after a leading `=`/`-` marker.
|
|
65
|
+
#
|
|
66
|
+
# @return [Boolean]
|
|
67
|
+
def correct_leading_marker(node, marker)
|
|
68
|
+
index = node.line - 1
|
|
69
|
+
line = autocorrected_lines[index]
|
|
70
|
+
escaped = Regexp.escape(marker)
|
|
71
|
+
correct_line(index, line.sub(/\A(\s*#{escaped})(?=[^\s#{escaped}])/, '\1 '))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Inserts a space after the `=` marker introducing a tag's inline script.
|
|
75
|
+
#
|
|
76
|
+
# @return [Boolean]
|
|
77
|
+
def correct_inline_script(node, text)
|
|
78
|
+
index = node.line - 1
|
|
79
|
+
line = autocorrected_lines[index]
|
|
80
|
+
|
|
81
|
+
pos = line.rindex("=#{text}")
|
|
82
|
+
if pos.nil? && (unquoted = strip_surrounding_quotes(text))
|
|
83
|
+
pos = line.rindex("=#{unquoted}")
|
|
84
|
+
end
|
|
85
|
+
return false unless pos
|
|
86
|
+
|
|
87
|
+
correct_line(index, "#{line[0..pos]} #{line[(pos + 1)..]}")
|
|
88
|
+
end
|
|
56
89
|
end
|
|
57
90
|
end
|
|
@@ -6,6 +6,8 @@ module HamlLint
|
|
|
6
6
|
class Linter::SpaceInsideHashAttributes < Linter
|
|
7
7
|
include LinterRegistry
|
|
8
8
|
|
|
9
|
+
supports_autocorrect(true)
|
|
10
|
+
|
|
9
11
|
STYLE = {
|
|
10
12
|
'no_space' => {
|
|
11
13
|
start_regex: /\A\{[^ ]/,
|
|
@@ -24,11 +26,47 @@ module HamlLint
|
|
|
24
26
|
def visit_tag(node)
|
|
25
27
|
return unless node.hash_attributes?
|
|
26
28
|
|
|
27
|
-
|
|
29
|
+
style_name = config['style'] == 'no_space' ? 'no_space' : 'space'
|
|
30
|
+
style = STYLE[style_name]
|
|
28
31
|
source = node.hash_attributes_source
|
|
29
32
|
|
|
30
|
-
|
|
31
|
-
|
|
33
|
+
start_ok = source.match?(style[:start_regex])
|
|
34
|
+
end_ok = source.match?(style[:end_regex])
|
|
35
|
+
return if start_ok && end_ok
|
|
36
|
+
|
|
37
|
+
corrected = correct_hash_spacing(node, source, style_name)
|
|
38
|
+
record_lint(node, style[:start_message], corrected: corrected) unless start_ok
|
|
39
|
+
record_lint(node, style[:end_message], corrected: corrected) unless end_ok
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# @return [Boolean]
|
|
45
|
+
def correct_hash_spacing(node, source, style_name)
|
|
46
|
+
return false unless source
|
|
47
|
+
return false if source.include?("\n") # multi-line hash: detection-only
|
|
48
|
+
|
|
49
|
+
index = node.line - 1
|
|
50
|
+
line = autocorrected_lines[index]
|
|
51
|
+
return false unless line.include?(source)
|
|
52
|
+
|
|
53
|
+
fixed = corrected_hash_source(source, style_name)
|
|
54
|
+
return false if fixed == source
|
|
55
|
+
|
|
56
|
+
correct_line(index, line.sub(source) { fixed })
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @return [String]
|
|
60
|
+
def corrected_hash_source(source, style_name)
|
|
61
|
+
inner = source[1...-1].strip
|
|
62
|
+
|
|
63
|
+
if style_name == 'no_space'
|
|
64
|
+
"{#{inner}}"
|
|
65
|
+
elsif inner.empty?
|
|
66
|
+
'{ }'
|
|
67
|
+
else
|
|
68
|
+
"{ #{inner} }"
|
|
69
|
+
end
|
|
32
70
|
end
|
|
33
71
|
end
|
|
34
72
|
end
|
|
@@ -5,11 +5,24 @@ module HamlLint
|
|
|
5
5
|
class Linter::TagName < Linter
|
|
6
6
|
include LinterRegistry
|
|
7
7
|
|
|
8
|
+
supports_autocorrect(true)
|
|
9
|
+
|
|
8
10
|
def visit_tag(node)
|
|
9
11
|
tag = node.tag_name
|
|
10
12
|
return unless /[A-Z]/.match?(tag)
|
|
11
13
|
|
|
12
|
-
|
|
14
|
+
corrected = correct_tag_name(node, tag)
|
|
15
|
+
record_lint(node, "`#{tag}` should be written in lowercase as `#{tag.downcase}`",
|
|
16
|
+
corrected: corrected)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
# @return [Boolean]
|
|
22
|
+
def correct_tag_name(node, tag)
|
|
23
|
+
index = node.line - 1
|
|
24
|
+
line = autocorrected_lines[index]
|
|
25
|
+
correct_line(index, line.sub(/(%)#{Regexp.escape(tag)}/, "\\1#{tag.downcase}"))
|
|
13
26
|
end
|
|
14
27
|
end
|
|
15
28
|
end
|
|
@@ -5,6 +5,8 @@ module HamlLint
|
|
|
5
5
|
class Linter::TrailingEmptyLines < Linter
|
|
6
6
|
include LinterRegistry
|
|
7
7
|
|
|
8
|
+
supports_autocorrect(true)
|
|
9
|
+
|
|
8
10
|
DummyNode = Struct.new(:line)
|
|
9
11
|
|
|
10
12
|
def visit_root(root)
|
|
@@ -16,7 +18,17 @@ module HamlLint
|
|
|
16
18
|
|
|
17
19
|
return unless document.source.end_with?("\n\n")
|
|
18
20
|
|
|
19
|
-
record_lint(line_number, 'Files should not end with trailing empty lines'
|
|
21
|
+
record_lint(line_number, 'Files should not end with trailing empty lines',
|
|
22
|
+
corrected: autocorrect?)
|
|
23
|
+
|
|
24
|
+
apply_autocorrect(corrected_source)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def corrected_source
|
|
30
|
+
last_non_empty_index = document.source_lines.rindex { |line| !line.empty? } || 0
|
|
31
|
+
"#{document.source_lines[0..last_non_empty_index].join("\n")}\n"
|
|
20
32
|
end
|
|
21
33
|
end
|
|
22
34
|
end
|
|
@@ -5,17 +5,30 @@ module HamlLint
|
|
|
5
5
|
class Linter::TrailingWhitespace < Linter
|
|
6
6
|
include LinterRegistry
|
|
7
7
|
|
|
8
|
+
supports_autocorrect(true)
|
|
9
|
+
|
|
8
10
|
DummyNode = Struct.new(:line)
|
|
9
11
|
|
|
10
12
|
def visit_root(root)
|
|
13
|
+
new_lines = document.source_lines.dup
|
|
14
|
+
changed = false
|
|
15
|
+
|
|
11
16
|
document.source_lines.each_with_index do |line, index|
|
|
12
17
|
next unless /\s+$/.match?(line)
|
|
13
18
|
|
|
14
19
|
node = root.node_for_line(index + 1)
|
|
15
|
-
|
|
16
|
-
|
|
20
|
+
next if node.disabled?(self)
|
|
21
|
+
|
|
22
|
+
if autocorrect?
|
|
23
|
+
new_lines[index] = line.sub(/\s+$/, '')
|
|
24
|
+
changed = true
|
|
17
25
|
end
|
|
26
|
+
|
|
27
|
+
record_lint(DummyNode.new(index + 1), 'Line contains trailing whitespace',
|
|
28
|
+
corrected: autocorrect?)
|
|
18
29
|
end
|
|
30
|
+
|
|
31
|
+
apply_autocorrect(new_lines.join("\n")) if changed
|
|
19
32
|
end
|
|
20
33
|
end
|
|
21
34
|
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HamlLint
|
|
4
|
+
# Flags Haml's unescaped-output markers (`!=`, `!~`, and the unescaped
|
|
5
|
+
# plain-text `!`), which bypass HTML escaping.
|
|
6
|
+
#
|
|
7
|
+
# Like `raw`, `html_safe`, and `h()` in Rails, these make it easy to
|
|
8
|
+
# accidentally introduce XSS vulnerabilities when the output includes
|
|
9
|
+
# user-controlled data, e.g.:
|
|
10
|
+
#
|
|
11
|
+
# != "Username: <strong>#{user.name}</strong>"
|
|
12
|
+
class Linter::UnescapedHtml < Linter
|
|
13
|
+
include LinterRegistry
|
|
14
|
+
|
|
15
|
+
MESSAGE =
|
|
16
|
+
'Avoid outputting unescaped HTML with `!`; it bypasses HTML escaping and ' \
|
|
17
|
+
'can introduce XSS vulnerabilities. Sanitize the value instead.'
|
|
18
|
+
|
|
19
|
+
def visit_script(node)
|
|
20
|
+
record_lint(node, MESSAGE) if /\A\s*!/.match?(node.source_code)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def visit_tag(node)
|
|
24
|
+
record_lint(node, MESSAGE) if node.unescape_html?
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -11,21 +11,48 @@ module HamlLint
|
|
|
11
11
|
class Linter::UnnecessaryInterpolation < Linter
|
|
12
12
|
include LinterRegistry
|
|
13
13
|
|
|
14
|
+
supports_autocorrect(true)
|
|
15
|
+
|
|
14
16
|
def visit_tag(node)
|
|
15
17
|
return if node.script.length <= 2
|
|
16
18
|
|
|
17
19
|
count = 0
|
|
18
20
|
chars = 2 # Include surrounding quote chars
|
|
19
|
-
|
|
21
|
+
interpolated_code = nil
|
|
22
|
+
HamlLint::Utils.extract_interpolated_values(node.script) do |code, _line|
|
|
20
23
|
count += 1
|
|
21
24
|
return if count > 1 # rubocop:disable Lint/NonLocalExitFromIterator
|
|
22
|
-
chars +=
|
|
25
|
+
chars += code.length + 3
|
|
26
|
+
interpolated_code = code
|
|
23
27
|
end
|
|
24
28
|
|
|
25
29
|
if chars == node.script.length
|
|
30
|
+
corrected = correct_interpolation(node, interpolated_code)
|
|
26
31
|
record_lint(node, '`%... \#{expression}` can be written without ' \
|
|
27
|
-
'interpolation as `%...= expression`')
|
|
32
|
+
'interpolation as `%...= expression`', corrected: corrected)
|
|
28
33
|
end
|
|
29
34
|
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# @return [Boolean]
|
|
39
|
+
def correct_interpolation(node, interpolated_code)
|
|
40
|
+
return false unless interpolated_code
|
|
41
|
+
|
|
42
|
+
index = node.line - 1
|
|
43
|
+
line = autocorrected_lines[index]
|
|
44
|
+
escaped = Regexp.escape(interpolated_code)
|
|
45
|
+
|
|
46
|
+
new_line =
|
|
47
|
+
if inline_content_is_string?(node)
|
|
48
|
+
line.sub(/=\s*"\#\{#{escaped}\}"\s*\z/, "= #{interpolated_code}")
|
|
49
|
+
else
|
|
50
|
+
line.sub(/\s+\#\{#{escaped}\}\s*\z/, "= #{interpolated_code}")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
return false if new_line == line
|
|
54
|
+
|
|
55
|
+
correct_line(index, new_line)
|
|
56
|
+
end
|
|
30
57
|
end
|
|
31
58
|
end
|
|
@@ -20,7 +20,7 @@ module HamlLint
|
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def visit_script(node)
|
|
23
|
-
# Some script nodes created by the
|
|
23
|
+
# Some script nodes created by the Haml parser aren't actually script
|
|
24
24
|
# nodes declared via the `=` marker. Check for it.
|
|
25
25
|
return unless /\A\s*=/.match?(node.source_code)
|
|
26
26
|
|
|
@@ -33,8 +33,11 @@ module HamlLint
|
|
|
33
33
|
|
|
34
34
|
def outputs_string_literal?(script_node)
|
|
35
35
|
return unless tree = parse_ruby(script_node.script)
|
|
36
|
-
%i[str dstr].include?(tree.type)
|
|
37
|
-
|
|
36
|
+
return unless %i[str dstr].include?(tree.type)
|
|
37
|
+
|
|
38
|
+
!starts_with_reserved_character?(tree.children.first) &&
|
|
39
|
+
!contains_escape_sequence?(tree) &&
|
|
40
|
+
!contains_significant_whitespace?(tree)
|
|
38
41
|
rescue ::Parser::SyntaxError
|
|
39
42
|
# Gracefully ignore syntax errors, as that's managed by a different linter
|
|
40
43
|
end
|
|
@@ -45,5 +48,34 @@ module HamlLint
|
|
|
45
48
|
string = stringish.respond_to?(:children) ? stringish.children.first : stringish
|
|
46
49
|
string =~ %r{\A\s*[/#-=%~]} if string.is_a?(String)
|
|
47
50
|
end
|
|
51
|
+
|
|
52
|
+
# The ordered segments of a string literal, including any interpolation.
|
|
53
|
+
# A plain `str` node has no interpolation, so it is its own only segment.
|
|
54
|
+
def string_segments(tree)
|
|
55
|
+
tree.type == :dstr ? tree.children : [tree]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Returns whether any literal portion of the string contains a backslash
|
|
59
|
+
# escape (e.g. `\n`, `\t`, `\u202F`). Such escapes are interpreted inside
|
|
60
|
+
# a Ruby string but would be emitted verbatim as Haml plain text, so the
|
|
61
|
+
# `= "..."` form is not equivalent to the unwrapped plain text.
|
|
62
|
+
def contains_escape_sequence?(tree)
|
|
63
|
+
string_segments(tree).any? do |segment|
|
|
64
|
+
segment.type == :str && segment.location.expression.source.include?('\\')
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Returns whether the string begins or ends with whitespace. Haml strips
|
|
69
|
+
# trailing whitespace from plain text (and leading whitespace denotes
|
|
70
|
+
# indentation), so unwrapping such a string would change the output.
|
|
71
|
+
def contains_significant_whitespace?(tree)
|
|
72
|
+
segments = string_segments(tree)
|
|
73
|
+
bounded_by_whitespace?(segments.first, /\A\s/) ||
|
|
74
|
+
bounded_by_whitespace?(segments.last, /\s\z/)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def bounded_by_whitespace?(segment, pattern)
|
|
78
|
+
segment.type == :str && segment.children.first.to_s.match?(pattern)
|
|
79
|
+
end
|
|
48
80
|
end
|
|
49
81
|
end
|