theme-check 1.5.1 → 1.6.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/theme-check.yml +12 -4
  3. data/CHANGELOG.md +35 -0
  4. data/docs/api/html_check.md +7 -7
  5. data/docs/api/liquid_check.md +10 -10
  6. data/docs/checks/convert_include_to_render.md +1 -1
  7. data/docs/checks/missing_enable_comment.md +1 -1
  8. data/lib/theme_check/analyzer.rb +20 -15
  9. data/lib/theme_check/asset_file.rb +13 -2
  10. data/lib/theme_check/check.rb +3 -3
  11. data/lib/theme_check/checks/asset_size_css.rb +15 -0
  12. data/lib/theme_check/checks/asset_size_css_stylesheet_tag.rb +18 -1
  13. data/lib/theme_check/checks/convert_include_to_render.rb +2 -1
  14. data/lib/theme_check/checks/html_parsing_error.rb +2 -2
  15. data/lib/theme_check/checks/liquid_tag.rb +1 -1
  16. data/lib/theme_check/checks/matching_translations.rb +1 -1
  17. data/lib/theme_check/checks/missing_required_template_files.rb +21 -7
  18. data/lib/theme_check/checks/missing_template.rb +6 -6
  19. data/lib/theme_check/checks/nested_snippet.rb +2 -2
  20. data/lib/theme_check/checks/required_directories.rb +3 -1
  21. data/lib/theme_check/checks/required_layout_theme_object.rb +2 -2
  22. data/lib/theme_check/checks/space_inside_braces.rb +47 -24
  23. data/lib/theme_check/checks/syntax_error.rb +5 -5
  24. data/lib/theme_check/checks/template_length.rb +2 -2
  25. data/lib/theme_check/checks/translation_key_exists.rb +3 -1
  26. data/lib/theme_check/checks/undefined_object.rb +7 -7
  27. data/lib/theme_check/checks/unused_assign.rb +4 -4
  28. data/lib/theme_check/checks/unused_snippet.rb +8 -6
  29. data/lib/theme_check/checks/valid_json.rb +1 -1
  30. data/lib/theme_check/checks.rb +4 -2
  31. data/lib/theme_check/cli.rb +7 -4
  32. data/lib/theme_check/corrector.rb +25 -12
  33. data/lib/theme_check/disabled_check.rb +3 -3
  34. data/lib/theme_check/disabled_checks.rb +9 -9
  35. data/lib/theme_check/file_system_storage.rb +13 -2
  36. data/lib/theme_check/html_node.rb +40 -32
  37. data/lib/theme_check/html_visitor.rb +24 -12
  38. data/lib/theme_check/in_memory_storage.rb +9 -1
  39. data/lib/theme_check/json_check.rb +2 -2
  40. data/lib/theme_check/json_file.rb +9 -4
  41. data/lib/theme_check/language_server/diagnostics_tracker.rb +8 -8
  42. data/lib/theme_check/{template.rb → liquid_file.rb} +6 -20
  43. data/lib/theme_check/liquid_node.rb +291 -0
  44. data/lib/theme_check/{visitor.rb → liquid_visitor.rb} +4 -4
  45. data/lib/theme_check/locale_diff.rb +5 -5
  46. data/lib/theme_check/node.rb +12 -118
  47. data/lib/theme_check/offense.rb +41 -15
  48. data/lib/theme_check/position.rb +28 -17
  49. data/lib/theme_check/position_helper.rb +13 -15
  50. data/lib/theme_check/regex_helpers.rb +1 -15
  51. data/lib/theme_check/remote_asset_file.rb +4 -0
  52. data/lib/theme_check/theme.rb +1 -1
  53. data/lib/theme_check/theme_file.rb +18 -1
  54. data/lib/theme_check/theme_file_rewriter.rb +57 -0
  55. data/lib/theme_check/version.rb +1 -1
  56. data/lib/theme_check.rb +11 -9
  57. data/theme-check.gemspec +2 -1
  58. 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, :template, :ranges
7
+ attr_reader :name, :theme_file, :ranges
8
8
  attr_accessor :first_line
9
9
 
10
- def initialize(template, name)
11
- @template = template
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
- template, check_name = key
15
- hash[key] = DisabledCheck.new(template, check_name)
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.template, check_name]]
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.template, check_name]]
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, template, check_name, index)
36
+ def disabled?(check, theme_file, check_name, index)
37
37
  return true if check.ignored_patterns&.any? do |pattern|
38
- template.relative_path.fnmatch?(pattern)
38
+ theme_file.relative_path.fnmatch?(pattern)
39
39
  end
40
40
 
41
- @disabled_checks[[template, :all]]&.disabled?(index) ||
42
- @disabled_checks[[template, check_name]]&.disabled?(index)
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.template, offense.code_name, offense.start_index)
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 :template, :parent
8
+ attr_reader :theme_file, :parent
9
9
 
10
- def initialize(value, template, placeholder_values = [], parent = nil)
10
+ def initialize(value, theme_file, placeholder_values = [], parent = nil)
11
11
  @value = value
12
- @template = template
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 {%#{i}####%} with the actual content.
71
- string.gsub(LIQUID_TAG) do |match|
72
- key = /\d+/.match(match)[0]
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 visit_template(template)
16
- doc = parse(template)
17
- visit(HtmlNode.new(doc, template, @placeholder_values))
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, template)
18
+ call_checks(:on_parse_error, e, liquid_file)
20
19
  end
21
20
 
22
21
  private
23
22
 
24
- def parse(template)
25
- parseable_source = +template.source.clone
23
+ def parse(liquid_file)
24
+ placeholder_values = []
25
+ parseable_source = +liquid_file.source.clone
26
26
 
27
- # Replace all liquid tags with {%#{i}######%} to prevent the HTML
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
- @placeholder_values.push(value)
33
- key = (@placeholder_values.size - 1).to_s
34
- parseable_source[m.begin(0)...m.end(0)] = "{%#{key.ljust(m.end(0) - m.begin(0) - 4, '#')}%}"
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
- Nokogiri::HTML5.fragment(parseable_source, max_tree_depth: 400, max_attributes: 400)
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 template
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, template: nil, &block)
8
- offenses << Offense.new(check: self, message: message, markup: markup, line_number: line_number, template: template, correction: block)
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
- if source != @content
29
- @storage.write(@relative_path, content)
30
- @source = content
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(&:template).each do |template, template_offenses|
23
- next unless template
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[template.path]
26
- if analyzed_files.nil? || analyzed_files.include?(template.path)
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[template.path] = reported_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 template.path, reported_offenses
37
- reported_files << template.path
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 template
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 Template < ThemeFile
4
+ class LiquidFile < ThemeFile
5
5
  def write
6
- content = updated_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 lines
30
- # Retain trailing newline character
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