theme-check 1.5.1 → 1.6.2
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/.github/workflows/theme-check.yml +12 -4
- data/CHANGELOG.md +35 -0
- data/docs/api/html_check.md +7 -7
- data/docs/api/liquid_check.md +10 -10
- data/docs/checks/convert_include_to_render.md +1 -1
- data/docs/checks/missing_enable_comment.md +1 -1
- data/lib/theme_check/analyzer.rb +20 -15
- data/lib/theme_check/asset_file.rb +13 -2
- data/lib/theme_check/check.rb +3 -3
- data/lib/theme_check/checks/asset_size_css.rb +15 -0
- data/lib/theme_check/checks/asset_size_css_stylesheet_tag.rb +18 -1
- data/lib/theme_check/checks/convert_include_to_render.rb +2 -1
- data/lib/theme_check/checks/html_parsing_error.rb +2 -2
- data/lib/theme_check/checks/liquid_tag.rb +1 -1
- data/lib/theme_check/checks/matching_translations.rb +1 -1
- data/lib/theme_check/checks/missing_required_template_files.rb +21 -7
- data/lib/theme_check/checks/missing_template.rb +6 -6
- data/lib/theme_check/checks/nested_snippet.rb +2 -2
- data/lib/theme_check/checks/required_directories.rb +3 -1
- data/lib/theme_check/checks/required_layout_theme_object.rb +2 -2
- data/lib/theme_check/checks/space_inside_braces.rb +47 -24
- data/lib/theme_check/checks/syntax_error.rb +5 -5
- data/lib/theme_check/checks/template_length.rb +2 -2
- data/lib/theme_check/checks/translation_key_exists.rb +3 -1
- data/lib/theme_check/checks/undefined_object.rb +7 -7
- data/lib/theme_check/checks/unused_assign.rb +4 -4
- data/lib/theme_check/checks/unused_snippet.rb +8 -6
- data/lib/theme_check/checks/valid_json.rb +1 -1
- data/lib/theme_check/checks.rb +4 -2
- data/lib/theme_check/cli.rb +7 -4
- data/lib/theme_check/corrector.rb +25 -12
- data/lib/theme_check/disabled_check.rb +3 -3
- data/lib/theme_check/disabled_checks.rb +9 -9
- data/lib/theme_check/file_system_storage.rb +13 -2
- data/lib/theme_check/html_node.rb +40 -32
- data/lib/theme_check/html_visitor.rb +24 -12
- data/lib/theme_check/in_memory_storage.rb +9 -1
- data/lib/theme_check/json_check.rb +2 -2
- data/lib/theme_check/json_file.rb +9 -4
- data/lib/theme_check/language_server/diagnostics_tracker.rb +8 -8
- data/lib/theme_check/{template.rb → liquid_file.rb} +6 -20
- data/lib/theme_check/liquid_node.rb +291 -0
- data/lib/theme_check/{visitor.rb → liquid_visitor.rb} +4 -4
- data/lib/theme_check/locale_diff.rb +5 -5
- data/lib/theme_check/node.rb +12 -118
- data/lib/theme_check/offense.rb +41 -15
- data/lib/theme_check/position.rb +28 -17
- data/lib/theme_check/position_helper.rb +13 -15
- data/lib/theme_check/regex_helpers.rb +1 -15
- data/lib/theme_check/remote_asset_file.rb +4 -0
- data/lib/theme_check/theme.rb +1 -1
- data/lib/theme_check/theme_file.rb +18 -1
- data/lib/theme_check/theme_file_rewriter.rb +57 -0
- data/lib/theme_check/version.rb +1 -1
- data/lib/theme_check.rb +11 -9
- data/theme-check.gemspec +2 -1
- metadata +22 -6
@@ -4,11 +4,11 @@
|
|
4
4
|
# We'll use the node position to figure out if the test is disabled or not.
|
5
5
|
module ThemeCheck
|
6
6
|
class DisabledCheck
|
7
|
-
attr_reader :name, :
|
7
|
+
attr_reader :name, :theme_file, :ranges
|
8
8
|
attr_accessor :first_line
|
9
9
|
|
10
|
-
def initialize(
|
11
|
-
@
|
10
|
+
def initialize(theme_file, name)
|
11
|
+
@theme_file = theme_file
|
12
12
|
@name = name
|
13
13
|
@ranges = []
|
14
14
|
@first_line = false
|
@@ -11,8 +11,8 @@ module ThemeCheck
|
|
11
11
|
|
12
12
|
def initialize
|
13
13
|
@disabled_checks = Hash.new do |hash, key|
|
14
|
-
|
15
|
-
hash[key] = DisabledCheck.new(
|
14
|
+
theme_file, check_name = key
|
15
|
+
hash[key] = DisabledCheck.new(theme_file, check_name)
|
16
16
|
end
|
17
17
|
end
|
18
18
|
|
@@ -20,26 +20,26 @@ module ThemeCheck
|
|
20
20
|
text = comment_text(node)
|
21
21
|
if start_disabling?(text)
|
22
22
|
checks_from_text(text).each do |check_name|
|
23
|
-
disabled = @disabled_checks[[node.
|
23
|
+
disabled = @disabled_checks[[node.theme_file, check_name]]
|
24
24
|
disabled.start_index = node.start_index
|
25
25
|
disabled.first_line = true if node.line_number == 1
|
26
26
|
end
|
27
27
|
elsif stop_disabling?(text)
|
28
28
|
checks_from_text(text).each do |check_name|
|
29
|
-
disabled = @disabled_checks[[node.
|
29
|
+
disabled = @disabled_checks[[node.theme_file, check_name]]
|
30
30
|
next unless disabled
|
31
31
|
disabled.end_index = node.end_index
|
32
32
|
end
|
33
33
|
end
|
34
34
|
end
|
35
35
|
|
36
|
-
def disabled?(check,
|
36
|
+
def disabled?(check, theme_file, check_name, index)
|
37
37
|
return true if check.ignored_patterns&.any? do |pattern|
|
38
|
-
|
38
|
+
theme_file.relative_path.fnmatch?(pattern)
|
39
39
|
end
|
40
40
|
|
41
|
-
@disabled_checks[[
|
42
|
-
@disabled_checks[[
|
41
|
+
@disabled_checks[[theme_file, :all]]&.disabled?(index) ||
|
42
|
+
@disabled_checks[[theme_file, check_name]]&.disabled?(index)
|
43
43
|
end
|
44
44
|
|
45
45
|
def checks_missing_end_index
|
@@ -51,7 +51,7 @@ module ThemeCheck
|
|
51
51
|
def remove_disabled_offenses(checks)
|
52
52
|
checks.disableable.each do |check|
|
53
53
|
check.offenses.reject! do |offense|
|
54
|
-
disabled?(check, offense.
|
54
|
+
disabled?(check, offense.theme_file, offense.code_name, offense.start_index)
|
55
55
|
end
|
56
56
|
end
|
57
57
|
end
|
@@ -16,14 +16,25 @@ module ThemeCheck
|
|
16
16
|
end
|
17
17
|
|
18
18
|
def read(relative_path)
|
19
|
-
file(relative_path).read
|
19
|
+
file(relative_path).read(mode: 'rb', encoding: 'UTF-8')
|
20
20
|
end
|
21
21
|
|
22
22
|
def write(relative_path, content)
|
23
23
|
reset_memoizers unless file_exists?(relative_path)
|
24
24
|
|
25
25
|
file(relative_path).dirname.mkpath unless file(relative_path).dirname.directory?
|
26
|
-
file(relative_path).write(content)
|
26
|
+
file(relative_path).write(content, mode: 'w+b', encoding: 'UTF-8')
|
27
|
+
end
|
28
|
+
|
29
|
+
def remove(relative_path)
|
30
|
+
file(relative_path).delete
|
31
|
+
reset_memoizers
|
32
|
+
end
|
33
|
+
|
34
|
+
def mkdir(relative_path)
|
35
|
+
reset_memoizers unless file_exists?(relative_path)
|
36
|
+
|
37
|
+
file(relative_path).mkpath unless file(relative_path).directory?
|
27
38
|
end
|
28
39
|
|
29
40
|
def files
|
@@ -2,18 +2,50 @@
|
|
2
2
|
require "forwardable"
|
3
3
|
|
4
4
|
module ThemeCheck
|
5
|
-
class HtmlNode
|
5
|
+
class HtmlNode < Node
|
6
6
|
extend Forwardable
|
7
7
|
include RegexHelpers
|
8
|
-
attr_reader :
|
8
|
+
attr_reader :theme_file, :parent
|
9
9
|
|
10
|
-
def initialize(value,
|
10
|
+
def initialize(value, theme_file, placeholder_values = [], parent = nil)
|
11
11
|
@value = value
|
12
|
-
@
|
12
|
+
@theme_file = theme_file
|
13
13
|
@placeholder_values = placeholder_values
|
14
14
|
@parent = parent
|
15
15
|
end
|
16
16
|
|
17
|
+
# @value is not forwarded because we _need_ to replace the
|
18
|
+
# placeholders for the HtmlNode to make sense.
|
19
|
+
def value
|
20
|
+
if literal?
|
21
|
+
content
|
22
|
+
else
|
23
|
+
markup
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def children
|
28
|
+
@children ||= @value
|
29
|
+
.children
|
30
|
+
.map { |child| HtmlNode.new(child, theme_file, @placeholder_values, self) }
|
31
|
+
end
|
32
|
+
|
33
|
+
def markup
|
34
|
+
@markup ||= replace_placeholders(@value.to_html)
|
35
|
+
end
|
36
|
+
|
37
|
+
def line_number
|
38
|
+
@value.line
|
39
|
+
end
|
40
|
+
|
41
|
+
def start_index
|
42
|
+
raise NotImplementedError
|
43
|
+
end
|
44
|
+
|
45
|
+
def end_index
|
46
|
+
raise NotImplementedError
|
47
|
+
end
|
48
|
+
|
17
49
|
def literal?
|
18
50
|
@value.name == "text"
|
19
51
|
end
|
@@ -22,12 +54,6 @@ module ThemeCheck
|
|
22
54
|
@value.element?
|
23
55
|
end
|
24
56
|
|
25
|
-
def children
|
26
|
-
@children ||= @value
|
27
|
-
.children
|
28
|
-
.map { |child| HtmlNode.new(child, template, @placeholder_values, self) }
|
29
|
-
end
|
30
|
-
|
31
57
|
def attributes
|
32
58
|
@attributes ||= @value.attributes
|
33
59
|
.map { |k, v| [replace_placeholders(k), replace_placeholders(v.value)] }
|
@@ -38,16 +64,6 @@ module ThemeCheck
|
|
38
64
|
@content ||= replace_placeholders(@value.content)
|
39
65
|
end
|
40
66
|
|
41
|
-
# @value is not forwarded because we _need_ to replace the
|
42
|
-
# placeholders for the HtmlNode to make sense.
|
43
|
-
def value
|
44
|
-
if literal?
|
45
|
-
content
|
46
|
-
else
|
47
|
-
markup
|
48
|
-
end
|
49
|
-
end
|
50
|
-
|
51
67
|
def name
|
52
68
|
if @value.name == "#document-fragment"
|
53
69
|
"document"
|
@@ -56,21 +72,13 @@ module ThemeCheck
|
|
56
72
|
end
|
57
73
|
end
|
58
74
|
|
59
|
-
def markup
|
60
|
-
@markup ||= replace_placeholders(@value.to_html)
|
61
|
-
end
|
62
|
-
|
63
|
-
def line_number
|
64
|
-
@value.line
|
65
|
-
end
|
66
|
-
|
67
75
|
private
|
68
76
|
|
69
77
|
def replace_placeholders(string)
|
70
|
-
# Replace all {
|
71
|
-
string.gsub(
|
72
|
-
key =
|
73
|
-
@placeholder_values[key.to_i]
|
78
|
+
# Replace all ≬{i}####≬ with the actual content.
|
79
|
+
string.gsub(HTML_LIQUID_PLACEHOLDER) do |match|
|
80
|
+
key = /[0-9a-z]+/.match(match)[0]
|
81
|
+
@placeholder_values[key.to_i(36)]
|
74
82
|
end
|
75
83
|
end
|
76
84
|
end
|
@@ -9,32 +9,44 @@ module ThemeCheck
|
|
9
9
|
|
10
10
|
def initialize(checks)
|
11
11
|
@checks = checks
|
12
|
-
@placeholder_values = []
|
13
12
|
end
|
14
13
|
|
15
|
-
def
|
16
|
-
doc = parse(
|
17
|
-
visit(HtmlNode.new(doc,
|
14
|
+
def visit_liquid_file(liquid_file)
|
15
|
+
doc, placeholder_values = parse(liquid_file)
|
16
|
+
visit(HtmlNode.new(doc, liquid_file, placeholder_values))
|
18
17
|
rescue ArgumentError => e
|
19
|
-
call_checks(:on_parse_error, e,
|
18
|
+
call_checks(:on_parse_error, e, liquid_file)
|
20
19
|
end
|
21
20
|
|
22
21
|
private
|
23
22
|
|
24
|
-
def parse(
|
25
|
-
|
23
|
+
def parse(liquid_file)
|
24
|
+
placeholder_values = []
|
25
|
+
parseable_source = +liquid_file.source.clone
|
26
26
|
|
27
|
-
# Replace all liquid tags with {
|
27
|
+
# Replace all non-empty liquid tags with ≬{i}######≬ to prevent the HTML
|
28
28
|
# parser from freaking out. We transparently replace those placeholders in
|
29
29
|
# HtmlNode.
|
30
|
+
#
|
31
|
+
# We're using base36 to prevent index bleeding on 36^3 tags.
|
32
|
+
# `{{x}}` -> `≬#{i}≬` would properly be transformed for 46656 tags in a single file.
|
33
|
+
# Should be enough.
|
34
|
+
#
|
35
|
+
# The base10 alternative would have overflowed at 1000 (`{{x}}` -> `≬1000≬`) which seemed more likely.
|
36
|
+
#
|
37
|
+
# Didn't go with base64 because of the `=` character that would have messed with HTML parsing.
|
30
38
|
matches(parseable_source, LIQUID_TAG_OR_VARIABLE).each do |m|
|
31
39
|
value = m[0]
|
32
|
-
|
33
|
-
|
34
|
-
|
40
|
+
next unless value.size > 4 # skip empty tags/variables {%%} and {{}}
|
41
|
+
placeholder_values.push(value)
|
42
|
+
key = (placeholder_values.size - 1).to_s(36)
|
43
|
+
parseable_source[m.begin(0)...m.end(0)] = "≬#{key.ljust(m.end(0) - m.begin(0) - 2, '#')}≬"
|
35
44
|
end
|
36
45
|
|
37
|
-
|
46
|
+
[
|
47
|
+
Nokogiri::HTML5.fragment(parseable_source, max_tree_depth: 400, max_attributes: 400),
|
48
|
+
placeholder_values,
|
49
|
+
]
|
38
50
|
end
|
39
51
|
|
40
52
|
def visit(node)
|
@@ -2,7 +2,7 @@
|
|
2
2
|
|
3
3
|
# An in-memory storage is not written to disk. The reasons why you'd
|
4
4
|
# want to do that are your own. The idea is to not write to disk
|
5
|
-
# something that doesn't need to be there. If you have your
|
5
|
+
# something that doesn't need to be there. If you have your theme
|
6
6
|
# as a big hash already, leave it like that and save yourself some IO.
|
7
7
|
module ThemeCheck
|
8
8
|
class InMemoryStorage < Storage
|
@@ -23,6 +23,14 @@ module ThemeCheck
|
|
23
23
|
@files[relative_path] = content
|
24
24
|
end
|
25
25
|
|
26
|
+
def remove(relative_path)
|
27
|
+
@files.delete(relative_path)
|
28
|
+
end
|
29
|
+
|
30
|
+
def mkdir(relative_path)
|
31
|
+
@files[relative_path] = nil
|
32
|
+
end
|
33
|
+
|
26
34
|
def files
|
27
35
|
@files.keys
|
28
36
|
end
|
@@ -4,8 +4,8 @@ module ThemeCheck
|
|
4
4
|
class JsonCheck < Check
|
5
5
|
extend ChecksTracking
|
6
6
|
|
7
|
-
def add_offense(message, markup: nil, line_number: nil,
|
8
|
-
offenses << Offense.new(check: self, message: message, markup: markup, line_number: line_number,
|
7
|
+
def add_offense(message, markup: nil, line_number: nil, theme_file: nil, &block)
|
8
|
+
offenses << Offense.new(check: self, message: message, markup: markup, line_number: line_number, theme_file: theme_file, correction: block)
|
9
9
|
end
|
10
10
|
end
|
11
11
|
end
|
@@ -20,14 +20,19 @@ module ThemeCheck
|
|
20
20
|
@parser_error
|
21
21
|
end
|
22
22
|
|
23
|
-
def update_contents(new_content =
|
23
|
+
def update_contents(new_content = {})
|
24
|
+
raise ArgumentError if new_content.is_a?(String)
|
24
25
|
@content = new_content
|
25
26
|
end
|
26
27
|
|
27
28
|
def write
|
28
|
-
|
29
|
-
|
30
|
-
|
29
|
+
pretty = JSON.pretty_generate(@content)
|
30
|
+
if source.rstrip != pretty.rstrip
|
31
|
+
# Most editors add a trailing \n at the end of files. Here we
|
32
|
+
# try to maintain the convention.
|
33
|
+
eof = source.end_with?("\n") ? "\n" : ""
|
34
|
+
@storage.write(@relative_path, pretty.gsub("\n", @eol) + eof)
|
35
|
+
@source = pretty
|
31
36
|
end
|
32
37
|
end
|
33
38
|
|
@@ -19,22 +19,22 @@ module ThemeCheck
|
|
19
19
|
new_single_file_offenses = {}
|
20
20
|
analyzed_files = analyzed_files.map { |path| Pathname.new(path) } if analyzed_files
|
21
21
|
|
22
|
-
offenses.group_by(&:
|
23
|
-
next unless
|
22
|
+
offenses.group_by(&:theme_file).each do |theme_file, template_offenses|
|
23
|
+
next unless theme_file
|
24
24
|
reported_offenses = template_offenses
|
25
|
-
previous_offenses = @single_files_offenses[
|
26
|
-
if analyzed_files.nil? || analyzed_files.include?(
|
25
|
+
previous_offenses = @single_files_offenses[theme_file.path]
|
26
|
+
if analyzed_files.nil? || analyzed_files.include?(theme_file.path)
|
27
27
|
# We re-analyzed the file, so we know the template_offenses are update to date.
|
28
28
|
reported_single_file_offenses = reported_offenses.select(&:single_file?)
|
29
29
|
if reported_single_file_offenses.any?
|
30
|
-
new_single_file_offenses[
|
30
|
+
new_single_file_offenses[theme_file.path] = reported_single_file_offenses
|
31
31
|
end
|
32
32
|
elsif previous_offenses
|
33
33
|
# Merge in the previous ones, if some
|
34
34
|
reported_offenses |= previous_offenses
|
35
35
|
end
|
36
|
-
yield
|
37
|
-
reported_files <<
|
36
|
+
yield theme_file.path, reported_offenses
|
37
|
+
reported_files << theme_file.path
|
38
38
|
end
|
39
39
|
|
40
40
|
@single_files_offenses.each do |path, _|
|
@@ -51,7 +51,7 @@ module ThemeCheck
|
|
51
51
|
reported_files << path
|
52
52
|
end
|
53
53
|
|
54
|
-
# Publish diagnostics with empty array if all issues on a previously reported
|
54
|
+
# Publish diagnostics with empty array if all issues on a previously reported theme_file
|
55
55
|
# have been fixed.
|
56
56
|
(@previously_reported_files - reported_files).each do |path|
|
57
57
|
yield path, []
|
@@ -1,12 +1,13 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module ThemeCheck
|
4
|
-
class
|
4
|
+
class LiquidFile < ThemeFile
|
5
5
|
def write
|
6
|
-
content =
|
6
|
+
content = rewriter.to_s
|
7
7
|
if source != content
|
8
|
-
@storage.write(@relative_path, content)
|
8
|
+
@storage.write(@relative_path, content.gsub("\n", @eol))
|
9
9
|
@source = content
|
10
|
+
@rewriter = nil
|
10
11
|
end
|
11
12
|
end
|
12
13
|
|
@@ -26,19 +27,8 @@ module ThemeCheck
|
|
26
27
|
name.start_with?('snippets')
|
27
28
|
end
|
28
29
|
|
29
|
-
def
|
30
|
-
|
31
|
-
@lines ||= source.split("\n", -1)
|
32
|
-
end
|
33
|
-
|
34
|
-
# Not entirely obvious but lines is mutable, corrections are to be
|
35
|
-
# applied on @lines.
|
36
|
-
def updated_content
|
37
|
-
lines.join("\n")
|
38
|
-
end
|
39
|
-
|
40
|
-
def excerpt(line)
|
41
|
-
lines[line - 1].strip
|
30
|
+
def rewriter
|
31
|
+
@rewriter ||= ThemeFileRewriter.new(@relative_path, source)
|
42
32
|
end
|
43
33
|
|
44
34
|
def source_excerpt(line)
|
@@ -46,10 +36,6 @@ module ThemeCheck
|
|
46
36
|
original_lines[line - 1].strip
|
47
37
|
end
|
48
38
|
|
49
|
-
def full_line(line)
|
50
|
-
lines[line - 1]
|
51
|
-
end
|
52
|
-
|
53
39
|
def parse
|
54
40
|
@ast ||= self.class.parse(source)
|
55
41
|
end
|
@@ -0,0 +1,291 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ThemeCheck
|
4
|
+
# A node from the Liquid AST, the result of parsing a liquid file.
|
5
|
+
class LiquidNode < Node
|
6
|
+
attr_reader :value, :parent, :theme_file
|
7
|
+
|
8
|
+
def initialize(value, parent, theme_file)
|
9
|
+
raise ArgumentError, "Expected a Liquid AST Node" if value.is_a?(LiquidNode)
|
10
|
+
@value = value
|
11
|
+
@parent = parent
|
12
|
+
@theme_file = theme_file
|
13
|
+
@tag_markup = nil
|
14
|
+
@line_number_offset = 0
|
15
|
+
end
|
16
|
+
|
17
|
+
# Array of children nodes.
|
18
|
+
def children
|
19
|
+
@children ||= begin
|
20
|
+
nodes =
|
21
|
+
if comment?
|
22
|
+
[]
|
23
|
+
elsif defined?(@value.class::ParseTreeVisitor)
|
24
|
+
@value.class::ParseTreeVisitor.new(@value, {}).children
|
25
|
+
elsif @value.respond_to?(:nodelist)
|
26
|
+
Array(@value.nodelist)
|
27
|
+
else
|
28
|
+
[]
|
29
|
+
end
|
30
|
+
# Work around a bug in Liquid::Variable::ParseTreeVisitor that doesn't return
|
31
|
+
# the args in a hash as children nodes.
|
32
|
+
nodes = nodes.flat_map do |node|
|
33
|
+
case node
|
34
|
+
when Hash
|
35
|
+
node.values
|
36
|
+
else
|
37
|
+
node
|
38
|
+
end
|
39
|
+
end
|
40
|
+
nodes.map { |node| LiquidNode.new(node, self, @theme_file) }
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
# The original source code of the node. Doesn't contain wrapping braces.
|
45
|
+
def markup
|
46
|
+
if tag?
|
47
|
+
tag_markup
|
48
|
+
elsif @value.instance_variable_defined?(:@markup)
|
49
|
+
@value.instance_variable_get(:@markup)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def markup=(markup)
|
54
|
+
if @value.instance_variable_defined?(:@markup)
|
55
|
+
@value.instance_variable_set(:@markup, markup)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
# Most nodes have a line number, but it's not guaranteed.
|
60
|
+
def line_number
|
61
|
+
if tag? && @value.respond_to?(:line_number)
|
62
|
+
markup # initialize the line_number_offset
|
63
|
+
@value.line_number - @line_number_offset
|
64
|
+
elsif @value.respond_to?(:line_number)
|
65
|
+
@value.line_number
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def start_index
|
70
|
+
position.start_index
|
71
|
+
end
|
72
|
+
|
73
|
+
def end_index
|
74
|
+
position.end_index
|
75
|
+
end
|
76
|
+
|
77
|
+
# Literals are hard-coded values in the liquid file.
|
78
|
+
def literal?
|
79
|
+
@value.is_a?(String) || @value.is_a?(Integer)
|
80
|
+
end
|
81
|
+
|
82
|
+
# A {% tag %} node?
|
83
|
+
def tag?
|
84
|
+
@value.is_a?(Liquid::Tag)
|
85
|
+
end
|
86
|
+
|
87
|
+
def variable?
|
88
|
+
@value.is_a?(Liquid::Variable)
|
89
|
+
end
|
90
|
+
|
91
|
+
# A {% comment %} block node?
|
92
|
+
def comment?
|
93
|
+
@value.is_a?(Liquid::Comment)
|
94
|
+
end
|
95
|
+
|
96
|
+
# Top level node of every liquid_file.
|
97
|
+
def document?
|
98
|
+
@value.is_a?(Liquid::Document)
|
99
|
+
end
|
100
|
+
alias_method :root?, :document?
|
101
|
+
|
102
|
+
# A {% tag %}...{% endtag %} node?
|
103
|
+
def block_tag?
|
104
|
+
@value.is_a?(Liquid::Block)
|
105
|
+
end
|
106
|
+
|
107
|
+
# The body of blocks
|
108
|
+
def block_body?
|
109
|
+
@value.is_a?(Liquid::BlockBody)
|
110
|
+
end
|
111
|
+
|
112
|
+
# A block of type of node?
|
113
|
+
def block?
|
114
|
+
block_tag? || block_body? || document?
|
115
|
+
end
|
116
|
+
|
117
|
+
# The `:under_score_name` of this type of node. Used to dispatch to the `on_<type_name>`
|
118
|
+
# and `after_<type_name>` check methods.
|
119
|
+
def type_name
|
120
|
+
@type_name ||= StringHelpers.underscore(StringHelpers.demodulize(@value.class.name)).to_sym
|
121
|
+
end
|
122
|
+
|
123
|
+
def source
|
124
|
+
theme_file&.source
|
125
|
+
end
|
126
|
+
|
127
|
+
WHITESPACE = /\s/
|
128
|
+
|
129
|
+
# Is this node inside a `{% liquid ... %}` block?
|
130
|
+
def inside_liquid_tag?
|
131
|
+
# What we're doing here is starting at the start of the tag and
|
132
|
+
# backtrack on all the whitespace until we land on something. If
|
133
|
+
# that something is {% or %-, then we can safely assume that
|
134
|
+
# we're inside a full tag and not a liquid tag.
|
135
|
+
@inside_liquid_tag ||= if tag? && start_index && source
|
136
|
+
i = 1
|
137
|
+
i += 1 while source[start_index - i] =~ WHITESPACE && i < start_index
|
138
|
+
first_two_backtracked_characters = source[(start_index - i - 1)..(start_index - i)]
|
139
|
+
first_two_backtracked_characters != "{%" && first_two_backtracked_characters != "%-"
|
140
|
+
else
|
141
|
+
false
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
# Is this node inside a tag or variable that starts by removing whitespace. i.e. {%- or {{-
|
146
|
+
def whitespace_trimmed_start?
|
147
|
+
@whitespace_trimmed_start ||= if start_index && source && !inside_liquid_tag?
|
148
|
+
i = 1
|
149
|
+
i += 1 while source[start_index - i] =~ WHITESPACE && i < start_index
|
150
|
+
source[start_index - i] == "-"
|
151
|
+
else
|
152
|
+
false
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
# Is this node inside a tag or variable ends starts by removing whitespace. i.e. -%} or -}}
|
157
|
+
def whitespace_trimmed_end?
|
158
|
+
@whitespace_trimmed_end ||= if end_index && source && !inside_liquid_tag?
|
159
|
+
i = 0
|
160
|
+
i += 1 while source[end_index + i] =~ WHITESPACE && i < source.size
|
161
|
+
source[end_index + i] == "-"
|
162
|
+
else
|
163
|
+
false
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
def start_token
|
168
|
+
return "" if inside_liquid_tag?
|
169
|
+
output = ""
|
170
|
+
output += "{{" if variable?
|
171
|
+
output += "{%" if tag?
|
172
|
+
output += "-" if whitespace_trimmed_start?
|
173
|
+
output
|
174
|
+
end
|
175
|
+
|
176
|
+
def end_token
|
177
|
+
return "" if inside_liquid_tag?
|
178
|
+
output = ""
|
179
|
+
output += "-" if whitespace_trimmed_end?
|
180
|
+
output += "}}" if variable?
|
181
|
+
output += "%}" if tag?
|
182
|
+
output
|
183
|
+
end
|
184
|
+
|
185
|
+
private
|
186
|
+
|
187
|
+
def position
|
188
|
+
@position ||= Position.new(
|
189
|
+
markup,
|
190
|
+
theme_file&.source,
|
191
|
+
line_number_1_indexed: line_number
|
192
|
+
)
|
193
|
+
end
|
194
|
+
|
195
|
+
# Here we're hacking around a glorious bug in Liquid that makes it so the
|
196
|
+
# line_number and markup of a tag is wrong if there's whitespace
|
197
|
+
# between the tag_name and the markup of the tag.
|
198
|
+
#
|
199
|
+
# {%
|
200
|
+
# render
|
201
|
+
# 'foo'
|
202
|
+
# %}
|
203
|
+
#
|
204
|
+
# Returns a raw value of "render 'foo'\n".
|
205
|
+
# The "\n " between render and 'foo' got replaced by a single space.
|
206
|
+
#
|
207
|
+
# And the line number is the one of 'foo'\n%}. Yay!
|
208
|
+
#
|
209
|
+
# This breaks any kind of position logic we have since that string
|
210
|
+
# does not exist in the theme_file.
|
211
|
+
def tag_markup
|
212
|
+
return @tag_markup if @tag_markup
|
213
|
+
|
214
|
+
l = 1
|
215
|
+
scanner = StringScanner.new(source)
|
216
|
+
scanner.scan_until(/\n/) while l < @value.line_number && (l += 1)
|
217
|
+
start = scanner.charpos
|
218
|
+
|
219
|
+
tag_name = @value.tag_name
|
220
|
+
tag_markup = @value.instance_variable_get('@markup')
|
221
|
+
|
222
|
+
# This is tricky, if the tag_markup is empty, then the tag could
|
223
|
+
# either start on a previous line, or the tag could start on the
|
224
|
+
# same line.
|
225
|
+
#
|
226
|
+
# Consider this:
|
227
|
+
# 1 {%
|
228
|
+
# 2 comment
|
229
|
+
# 3 %}{% endcomment %}{%comment%}
|
230
|
+
#
|
231
|
+
# Both comments would markup == "" AND line_number == 3
|
232
|
+
#
|
233
|
+
# There's no way to determine which one is the correct one, but
|
234
|
+
# we'll try our best to at least give you one.
|
235
|
+
#
|
236
|
+
# To screw with you even more, the name of the tag could be
|
237
|
+
# outside of a tag on the same line :) But I won't do anything
|
238
|
+
# about that (yet?).
|
239
|
+
#
|
240
|
+
# {% comment
|
241
|
+
# %}comment{% endcomment %}
|
242
|
+
if tag_markup.empty?
|
243
|
+
eol = source.index("\n", start) || source.size
|
244
|
+
|
245
|
+
# OK here I'm trying one of two things. Either tag_start is on
|
246
|
+
# the same line OR tag_start is on a previous line. The line
|
247
|
+
# number would be at the end of the whitespace after tag_name.
|
248
|
+
unless (tag_start = source.index(tag_name, start)) && tag_start < eol
|
249
|
+
tag_start = start
|
250
|
+
tag_start -= 1 while source[tag_start - 1] =~ WHITESPACE
|
251
|
+
tag_start -= @value.tag_name.size
|
252
|
+
|
253
|
+
# keep track of the error in line_number
|
254
|
+
@line_number_offset = source[tag_start...start].count("\n")
|
255
|
+
end
|
256
|
+
tag_end = tag_start + tag_name.size
|
257
|
+
tag_end += 1 while source[tag_end] =~ WHITESPACE
|
258
|
+
|
259
|
+
# return the real raw content
|
260
|
+
@tag_markup = source[tag_start...tag_end]
|
261
|
+
return @tag_markup
|
262
|
+
|
263
|
+
# See https://github.com/Shopify/theme-check/pull/423/files#r701936559 for a detailed explanation
|
264
|
+
# of why we're doing the check below.
|
265
|
+
#
|
266
|
+
# TL;DR it's because line_numbers are not enough to accurately
|
267
|
+
# determine the position of the raw markup and because that
|
268
|
+
# markup could be present on the same line outside of a Tag. e.g.
|
269
|
+
#
|
270
|
+
# uhoh {% if uhoh %}
|
271
|
+
elsif (match = /#{tag_name} +#{Regexp.escape(tag_markup)}/.match(source, start))
|
272
|
+
return @tag_markup = match[0]
|
273
|
+
end
|
274
|
+
|
275
|
+
# find the markup
|
276
|
+
markup_start = source.index(tag_markup, start)
|
277
|
+
markup_end = markup_start + tag_markup.size
|
278
|
+
|
279
|
+
# go back until you find the tag_name
|
280
|
+
tag_start = markup_start
|
281
|
+
tag_start -= 1 while source[tag_start - 1] =~ WHITESPACE
|
282
|
+
tag_start -= tag_name.size
|
283
|
+
|
284
|
+
# keep track of the error in line_number
|
285
|
+
@line_number_offset = source[tag_start...markup_start].count("\n")
|
286
|
+
|
287
|
+
# return the real raw content
|
288
|
+
@tag_markup = source[tag_start...markup_end]
|
289
|
+
end
|
290
|
+
end
|
291
|
+
end
|