theme-check 1.5.0 → 1.6.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/theme-check.yml +12 -4
  3. data/CHANGELOG.md +34 -0
  4. data/CONTRIBUTING.md +58 -0
  5. data/Gemfile +3 -0
  6. data/docs/flamegraph.svg +18488 -0
  7. data/lib/theme_check/analyzer.rb +5 -0
  8. data/lib/theme_check/asset_file.rb +13 -2
  9. data/lib/theme_check/check.rb +1 -1
  10. data/lib/theme_check/checks/asset_size_css.rb +15 -0
  11. data/lib/theme_check/checks/asset_size_css_stylesheet_tag.rb +18 -1
  12. data/lib/theme_check/checks/convert_include_to_render.rb +2 -1
  13. data/lib/theme_check/checks/liquid_tag.rb +1 -1
  14. data/lib/theme_check/checks/missing_required_template_files.rb +21 -7
  15. data/lib/theme_check/checks/pagination_size.rb +30 -10
  16. data/lib/theme_check/checks/required_directories.rb +3 -1
  17. data/lib/theme_check/checks/space_inside_braces.rb +47 -24
  18. data/lib/theme_check/checks/translation_key_exists.rb +3 -1
  19. data/lib/theme_check/checks/unused_snippet.rb +3 -1
  20. data/lib/theme_check/checks.rb +2 -0
  21. data/lib/theme_check/cli.rb +32 -6
  22. data/lib/theme_check/corrector.rb +23 -10
  23. data/lib/theme_check/file_system_storage.rb +13 -2
  24. data/lib/theme_check/html_node.rb +4 -4
  25. data/lib/theme_check/html_visitor.rb +20 -8
  26. data/lib/theme_check/in_memory_storage.rb +8 -0
  27. data/lib/theme_check/json_file.rb +9 -4
  28. data/lib/theme_check/json_printer.rb +5 -1
  29. data/lib/theme_check/node.rb +118 -11
  30. data/lib/theme_check/offense.rb +26 -0
  31. data/lib/theme_check/position.rb +27 -16
  32. data/lib/theme_check/position_helper.rb +13 -15
  33. data/lib/theme_check/printer.rb +9 -5
  34. data/lib/theme_check/regex_helpers.rb +1 -15
  35. data/lib/theme_check/remote_asset_file.rb +4 -0
  36. data/lib/theme_check/template.rb +5 -19
  37. data/lib/theme_check/template_rewriter.rb +57 -0
  38. data/lib/theme_check/theme_file.rb +18 -1
  39. data/lib/theme_check/version.rb +1 -1
  40. data/lib/theme_check.rb +1 -0
  41. data/theme-check.gemspec +1 -0
  42. metadata +18 -2
@@ -7,25 +7,20 @@ module ThemeCheck
7
7
  end
8
8
 
9
9
  def insert_after(node, content)
10
- line = @template.full_line(node.line_number)
11
- line.insert(node.range[1] + 1, content)
10
+ @template.rewriter.insert_after(node, content)
12
11
  end
13
12
 
14
13
  def insert_before(node, content)
15
- line = @template.full_line(node.line_number)
16
- line.insert(node.range[0], content)
14
+ @template.rewriter.insert_before(node, content)
17
15
  end
18
16
 
19
17
  def replace(node, content)
20
- line = @template.full_line(node.line_number)
21
- line[node.range[0]..node.range[1]] = content
18
+ @template.rewriter.replace(node, content)
22
19
  node.markup = content
23
20
  end
24
21
 
25
22
  def wrap(node, insert_before, insert_after)
26
- line = @template.full_line(node.line_number)
27
- line.insert(node.range[0], insert_before)
28
- line.insert(node.range[1] + 1 + insert_before.length, insert_after)
23
+ @template.rewriter.wrap(node, insert_before, insert_after)
29
24
  end
30
25
 
31
26
  def create(theme, relative_path, content)
@@ -34,7 +29,25 @@ module ThemeCheck
34
29
 
35
30
  def create_default_locale_json(theme)
36
31
  theme.default_locale_json = JsonFile.new("locales/#{theme.default_locale}.default.json", theme.storage)
37
- theme.default_locale_json.update_contents('{}')
32
+ theme.default_locale_json.update_contents({})
33
+ end
34
+
35
+ def remove(theme, relative_path)
36
+ theme.storage.remove(relative_path)
37
+ end
38
+
39
+ def mkdir(theme, relative_path)
40
+ theme.storage.mkdir(relative_path)
41
+ end
42
+
43
+ def add_default_translation_key(file, key, value)
44
+ hash = file.content
45
+ key.reduce(hash) do |pointer, token|
46
+ return pointer[token] = value if token == key.last
47
+ pointer[token] = {} unless pointer.key?(token)
48
+ pointer[token]
49
+ end
50
+ file.update_contents(hash)
38
51
  end
39
52
  end
40
53
  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
@@ -67,10 +67,10 @@ module ThemeCheck
67
67
  private
68
68
 
69
69
  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]
70
+ # Replace all {i}####≬ with the actual content.
71
+ string.gsub(HTML_LIQUID_PLACEHOLDER) do |match|
72
+ key = /[0-9a-z]+/.match(match)[0]
73
+ @placeholder_values[key.to_i(36)]
74
74
  end
75
75
  end
76
76
  end
@@ -9,12 +9,11 @@ module ThemeCheck
9
9
 
10
10
  def initialize(checks)
11
11
  @checks = checks
12
- @placeholder_values = []
13
12
  end
14
13
 
15
14
  def visit_template(template)
16
- doc = parse(template)
17
- visit(HtmlNode.new(doc, template, @placeholder_values))
15
+ doc, placeholder_values = parse(template)
16
+ visit(HtmlNode.new(doc, template, placeholder_values))
18
17
  rescue ArgumentError => e
19
18
  call_checks(:on_parse_error, e, template)
20
19
  end
@@ -22,19 +21,32 @@ module ThemeCheck
22
21
  private
23
22
 
24
23
  def parse(template)
24
+ placeholder_values = []
25
25
  parseable_source = +template.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)
@@ -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
@@ -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
 
@@ -3,9 +3,13 @@ require 'json'
3
3
 
4
4
  module ThemeCheck
5
5
  class JsonPrinter
6
+ def initialize(out_stream = STDOUT)
7
+ @out = out_stream
8
+ end
9
+
6
10
  def print(offenses)
7
11
  json = offenses_by_path(offenses)
8
- puts JSON.dump(json)
12
+ @out.puts JSON.dump(json)
9
13
  end
10
14
 
11
15
  def offenses_by_path(offenses)
@@ -10,12 +10,14 @@ module ThemeCheck
10
10
  @value = value
11
11
  @parent = parent
12
12
  @template = template
13
+ @tag_markup = nil
14
+ @line_number_offset = 0
13
15
  end
14
16
 
15
17
  # The original source code of the node. Doesn't contain wrapping braces.
16
18
  def markup
17
19
  if tag?
18
- @value.raw
20
+ tag_markup
19
21
  elsif @value.instance_variable_defined?(:@markup)
20
22
  @value.instance_variable_get(:@markup)
21
23
  end
@@ -64,6 +66,10 @@ module ThemeCheck
64
66
  @value.is_a?(Liquid::Tag)
65
67
  end
66
68
 
69
+ def variable?
70
+ @value.is_a?(Liquid::Variable)
71
+ end
72
+
67
73
  # A {% comment %} block node?
68
74
  def comment?
69
75
  @value.is_a?(Liquid::Comment)
@@ -92,7 +98,12 @@ module ThemeCheck
92
98
 
93
99
  # Most nodes have a line number, but it's not guaranteed.
94
100
  def line_number
95
- @value.line_number if @value.respond_to?(:line_number)
101
+ if tag? && @value.respond_to?(:line_number)
102
+ markup # initialize the line_number_offset
103
+ @value.line_number - @line_number_offset
104
+ elsif @value.respond_to?(:line_number)
105
+ @value.line_number
106
+ end
96
107
  end
97
108
 
98
109
  # The `:under_score_name` of this type of node. Used to dispatch to the `on_<type_name>`
@@ -101,27 +112,48 @@ module ThemeCheck
101
112
  @type_name ||= StringHelpers.underscore(StringHelpers.demodulize(@value.class.name)).to_sym
102
113
  end
103
114
 
115
+ def source
116
+ template&.source
117
+ end
118
+
119
+ WHITESPACE = /\s/
120
+
104
121
  # Is this node inside a `{% liquid ... %}` block?
105
122
  def inside_liquid_tag?
106
- if line_number
107
- template.excerpt(line_number).start_with?("{%")
123
+ # What we're doing here is starting at the start of the tag and
124
+ # backtrack on all the whitespace until we land on something. If
125
+ # that something is {% or %-, then we can safely assume that
126
+ # we're inside a full tag and not a liquid tag.
127
+ @inside_liquid_tag ||= if tag? && line_number && source
128
+ i = 1
129
+ i += 1 while source[start_index - i] =~ WHITESPACE && i < start_index
130
+ first_two_backtracked_characters = source[(start_index - i - 1)..(start_index - i)]
131
+ first_two_backtracked_characters != "{%" && first_two_backtracked_characters != "%-"
108
132
  else
109
133
  false
110
134
  end
111
135
  end
112
136
 
113
- # Is this node inside a `{%- ... -%}`
114
- def whitespace_trimmed?
115
- if line_number
116
- template.excerpt(line_number).start_with?("{%-")
137
+ # Is this node inside a tag or variable that starts by removing whitespace. i.e. {%- or {{-
138
+ def whitespace_trimmed_start?
139
+ @whitespace_trimmed_start ||= if line_number && source && !inside_liquid_tag?
140
+ i = 1
141
+ i += 1 while source[start_index - i] =~ WHITESPACE && i < start_index
142
+ source[start_index - i] == "-"
117
143
  else
118
144
  false
119
145
  end
120
146
  end
121
147
 
122
- def range
123
- start = template.full_line(line_number).index(markup)
124
- [start, start + markup.length - 1]
148
+ # Is this node inside a tag or variable ends starts by removing whitespace. i.e. -%} or -}}
149
+ def whitespace_trimmed_end?
150
+ @whitespace_trimmed_end ||= if line_number && source && !inside_liquid_tag?
151
+ i = 0
152
+ i += 1 while source[end_index + i] =~ WHITESPACE && i < source.size
153
+ source[end_index + i] == "-"
154
+ else
155
+ false
156
+ end
125
157
  end
126
158
 
127
159
  def position
@@ -139,5 +171,80 @@ module ThemeCheck
139
171
  def end_index
140
172
  position.end_index
141
173
  end
174
+
175
+ def start_token
176
+ return "" if inside_liquid_tag?
177
+ output = ""
178
+ output += "{{" if variable?
179
+ output += "{%" if tag?
180
+ output += "-" if whitespace_trimmed_start?
181
+ output
182
+ end
183
+
184
+ def end_token
185
+ return "" if inside_liquid_tag?
186
+ output = ""
187
+ output += "-" if whitespace_trimmed_end?
188
+ output += "}}" if variable?
189
+ output += "%}" if tag?
190
+ output
191
+ end
192
+
193
+ private
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 template.
211
+ def tag_markup
212
+ return @value.tag_name if @value.instance_variable_get('@markup').empty?
213
+ return @tag_markup if @tag_markup
214
+
215
+ l = 1
216
+ scanner = StringScanner.new(source)
217
+ scanner.scan_until(/\n/) while l < @value.line_number && (l += 1)
218
+ start = scanner.charpos
219
+
220
+ tag_markup = @value.instance_variable_get('@markup')
221
+
222
+ # See https://github.com/Shopify/theme-check/pull/423/files#r701936559 for a detailed explanation
223
+ # of why we're doing the check below.
224
+ #
225
+ # TL;DR it's because line_numbers are not enough to accurately
226
+ # determine the position of the raw markup and because that
227
+ # markup could be present on the same line outside of a Tag. e.g.
228
+ #
229
+ # uhoh {% if uhoh %}
230
+ if (match = /#{@value.tag_name} +#{Regexp.escape(tag_markup)}/.match(source, start))
231
+ return @tag_markup = match[0]
232
+ end
233
+
234
+ # find the markup
235
+ markup_start = source.index(tag_markup, start)
236
+ markup_end = markup_start + tag_markup.size
237
+
238
+ # go back until you find the tag_name
239
+ tag_start = markup_start
240
+ tag_start -= 1 while source[tag_start - 1] =~ WHITESPACE
241
+ tag_start -= @value.tag_name.size
242
+
243
+ # keep track of the error in line_number
244
+ @line_number_offset = source[tag_start...markup_start].count("\n")
245
+
246
+ # return the real raw content
247
+ @tag_markup = source[tag_start...markup_end]
248
+ end
142
249
  end
143
250
  end
@@ -144,6 +144,32 @@ module ThemeCheck
144
144
  corrector = Corrector.new(template: template)
145
145
  correction.call(corrector)
146
146
  end
147
+ rescue => e
148
+ ThemeCheck.bug(<<~EOS)
149
+ Exception while running `Offense#correct`:
150
+ ```
151
+ #{e.class}: #{e.message}
152
+ #{e.backtrace.join("\n ")}
153
+ ```
154
+
155
+ Offense:
156
+ ```
157
+ #{JSON.pretty_generate(to_h)}
158
+ ```
159
+ Check options:
160
+ ```
161
+ #{check.options.pretty_inspect}
162
+ ```
163
+ Markup:
164
+ ```
165
+ #{markup}
166
+ ```
167
+ Node.Markup:
168
+ ```
169
+ #{node&.markup}
170
+ ```
171
+ EOS
172
+ exit(2)
147
173
  end
148
174
 
149
175
  def whole_theme?
@@ -16,22 +16,22 @@ module ThemeCheck
16
16
  @line_number_1_indexed = line_number_1_indexed
17
17
  @node_markup_offset = node_markup_offset
18
18
  @node_markup = node_markup
19
- @strict_position = StrictPosition.new(
20
- needle,
21
- contents,
22
- start_index,
23
- )
24
19
  end
25
20
 
26
21
  def start_line_offset
27
- from_row_column_to_index(contents, line_number, 0)
22
+ @start_line_offset ||= from_row_column_to_index(contents, line_number, 0)
28
23
  end
29
24
 
30
25
  def start_offset
31
- return start_line_offset if @node_markup.nil?
32
- node_markup_start = contents.index(@node_markup, start_line_offset)
33
- return start_line_offset if node_markup_start.nil?
34
- node_markup_start + @node_markup_offset
26
+ @start_offset ||= compute_start_offset
27
+ end
28
+
29
+ def strict_position
30
+ @strict_position ||= StrictPosition.new(
31
+ needle,
32
+ contents,
33
+ start_index,
34
+ )
35
35
  end
36
36
 
37
37
  # 0-indexed, inclusive
@@ -41,39 +41,50 @@ module ThemeCheck
41
41
 
42
42
  # 0-indexed, exclusive
43
43
  def end_index
44
- @strict_position.end_index
44
+ strict_position.end_index
45
45
  end
46
46
 
47
47
  # 0-indexed, inclusive
48
48
  def start_row
49
- @strict_position.start_row
49
+ strict_position.start_row
50
50
  end
51
51
 
52
52
  # 0-indexed, inclusive
53
53
  def start_column
54
- @strict_position.start_column
54
+ strict_position.start_column
55
55
  end
56
56
 
57
57
  # 0-indexed, exclusive (both taken together are) therefore you
58
58
  # might end up on a newline character or the next line
59
59
  def end_row
60
- @strict_position.end_row
60
+ strict_position.end_row
61
61
  end
62
62
 
63
63
  def end_column
64
- @strict_position.end_column
64
+ strict_position.end_column
65
65
  end
66
66
 
67
67
  private
68
68
 
69
+ def compute_start_offset
70
+ return start_line_offset if @node_markup.nil?
71
+ node_markup_start = contents.index(@node_markup, start_line_offset)
72
+ return start_line_offset if node_markup_start.nil?
73
+ node_markup_start + @node_markup_offset
74
+ end
75
+
69
76
  def contents
70
77
  return '' unless @contents.is_a?(String) && !@contents.empty?
71
78
  @contents
72
79
  end
73
80
 
81
+ def content_line_count
82
+ @content_line_count ||= contents.count("\n")
83
+ end
84
+
74
85
  def line_number
75
86
  return 0 if @line_number_1_indexed.nil?
76
- bounded(0, @line_number_1_indexed - 1, contents.lines.size - 1)
87
+ bounded(0, @line_number_1_indexed - 1, content_line_count)
77
88
  end
78
89
 
79
90
  def needle