theme-check 0.8.0 → 0.9.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/theme-check.yml +3 -0
  3. data/CHANGELOG.md +44 -0
  4. data/CONTRIBUTING.md +2 -1
  5. data/README.md +4 -1
  6. data/RELEASING.md +5 -3
  7. data/config/default.yml +42 -1
  8. data/data/shopify_liquid/tags.yml +3 -0
  9. data/data/shopify_translation_keys.yml +1 -0
  10. data/docs/checks/asset_url_filters.md +56 -0
  11. data/docs/checks/content_for_header_modification.md +42 -0
  12. data/docs/checks/nested_snippet.md +1 -1
  13. data/docs/checks/parser_blocking_script_tag.md +53 -0
  14. data/docs/checks/space_inside_braces.md +28 -0
  15. data/exe/theme-check-language-server +1 -2
  16. data/lib/theme_check.rb +13 -1
  17. data/lib/theme_check/analyzer.rb +79 -13
  18. data/lib/theme_check/bug.rb +20 -0
  19. data/lib/theme_check/check.rb +36 -7
  20. data/lib/theme_check/checks.rb +47 -8
  21. data/lib/theme_check/checks/asset_url_filters.rb +46 -0
  22. data/lib/theme_check/checks/content_for_header_modification.rb +41 -0
  23. data/lib/theme_check/checks/img_width_and_height.rb +18 -49
  24. data/lib/theme_check/checks/missing_enable_comment.rb +4 -4
  25. data/lib/theme_check/checks/missing_template.rb +1 -0
  26. data/lib/theme_check/checks/nested_snippet.rb +1 -1
  27. data/lib/theme_check/checks/parser_blocking_javascript.rb +6 -38
  28. data/lib/theme_check/checks/parser_blocking_script_tag.rb +20 -0
  29. data/lib/theme_check/checks/remote_asset.rb +21 -79
  30. data/lib/theme_check/checks/space_inside_braces.rb +8 -2
  31. data/lib/theme_check/checks/template_length.rb +3 -0
  32. data/lib/theme_check/checks/valid_html_translation.rb +1 -0
  33. data/lib/theme_check/config.rb +2 -0
  34. data/lib/theme_check/disabled_check.rb +41 -0
  35. data/lib/theme_check/disabled_checks.rb +33 -29
  36. data/lib/theme_check/exceptions.rb +32 -0
  37. data/lib/theme_check/html_check.rb +7 -0
  38. data/lib/theme_check/html_node.rb +56 -0
  39. data/lib/theme_check/html_visitor.rb +38 -0
  40. data/lib/theme_check/json_file.rb +13 -1
  41. data/lib/theme_check/language_server.rb +2 -1
  42. data/lib/theme_check/language_server/completion_engine.rb +1 -1
  43. data/lib/theme_check/language_server/completion_providers/filter_completion_provider.rb +1 -0
  44. data/lib/theme_check/language_server/completion_providers/object_completion_provider.rb +10 -8
  45. data/lib/theme_check/language_server/constants.rb +5 -1
  46. data/lib/theme_check/language_server/diagnostics_tracker.rb +64 -0
  47. data/lib/theme_check/language_server/document_link_engine.rb +2 -2
  48. data/lib/theme_check/language_server/handler.rb +63 -50
  49. data/lib/theme_check/language_server/server.rb +1 -1
  50. data/lib/theme_check/language_server/variable_lookup_finder.rb +295 -0
  51. data/lib/theme_check/liquid_check.rb +1 -4
  52. data/lib/theme_check/node.rb +12 -0
  53. data/lib/theme_check/offense.rb +30 -46
  54. data/lib/theme_check/position.rb +77 -0
  55. data/lib/theme_check/position_helper.rb +37 -0
  56. data/lib/theme_check/remote_asset_file.rb +3 -0
  57. data/lib/theme_check/shopify_liquid/tag.rb +13 -0
  58. data/lib/theme_check/template.rb +8 -0
  59. data/lib/theme_check/theme.rb +7 -2
  60. data/lib/theme_check/version.rb +1 -1
  61. data/lib/theme_check/visitor.rb +4 -14
  62. metadata +19 -4
  63. data/lib/theme_check/language_server/position_helper.rb +0 -27
@@ -52,7 +52,7 @@ module ThemeCheck
52
52
  response_body = JSON.dump(response)
53
53
  log(JSON.pretty_generate(response)) if $DEBUG
54
54
 
55
- @out.write("Content-Length: #{response_body.size}\r\n")
55
+ @out.write("Content-Length: #{response_body.bytesize}\r\n")
56
56
  @out.write("\r\n")
57
57
  @out.write(response_body)
58
58
  @out.flush
@@ -0,0 +1,295 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ module LanguageServer
5
+ module VariableLookupFinder
6
+ extend self
7
+
8
+ UNCLOSED_SQUARE_BRACKET = /\[[^\]]*\Z/
9
+ ENDS_IN_BRACKET_POSITION_THAT_CANT_BE_COMPLETED = %r{
10
+ (
11
+ # quotes not preceded by a [
12
+ (?<!\[)['"]|
13
+ # closing ]
14
+ \]|
15
+ # opening [
16
+ \[
17
+ )$
18
+ }x
19
+
20
+ VARIABLE_LOOKUP_CHARACTERS = /[a-z0-9_.'"\]\[]/i
21
+ VARIABLE_LOOKUP = /#{VARIABLE_LOOKUP_CHARACTERS}+/o
22
+ SYMBOLS_PRECEDING_POTENTIAL_LOOKUPS = %r{
23
+ (?:
24
+ \s(?:
25
+ if|elsif|unless|and|or|#{Liquid::Condition.operators.keys.join("|")}
26
+ |echo
27
+ |case|when
28
+ |cycle
29
+ |in
30
+ )
31
+ |[:,=]
32
+ )
33
+ \s+
34
+ }omix
35
+ ENDS_WITH_BLANK_POTENTIAL_LOOKUP = /#{SYMBOLS_PRECEDING_POTENTIAL_LOOKUPS}$/oimx
36
+ ENDS_WITH_POTENTIAL_LOOKUP = /#{SYMBOLS_PRECEDING_POTENTIAL_LOOKUPS}#{VARIABLE_LOOKUP}$/oimx
37
+
38
+ def lookup(content, cursor)
39
+ return if cursor_is_on_bracket_position_that_cant_be_completed(content, cursor)
40
+ potential_lookup = lookup_liquid_variable(content, cursor) || lookup_liquid_tag(content, cursor)
41
+
42
+ # And we only return it if it's parsed by Liquid as VariableLookup
43
+ return unless potential_lookup.is_a?(Liquid::VariableLookup)
44
+ potential_lookup
45
+ end
46
+
47
+ private
48
+
49
+ def cursor_is_on_bracket_position_that_cant_be_completed(content, cursor)
50
+ content[0..cursor - 1] =~ ENDS_IN_BRACKET_POSITION_THAT_CANT_BE_COMPLETED
51
+ end
52
+
53
+ def cursor_is_on_liquid_variable_lookup_position(content, cursor)
54
+ previous_char = content[cursor - 1]
55
+ is_liquid_variable = content =~ Liquid::VariableStart
56
+ is_in_variable_segment = previous_char =~ VARIABLE_LOOKUP_CHARACTERS
57
+ is_on_blank_variable_lookup_position = content[0..cursor - 1] =~ /[{:,-]\s+$/
58
+ (
59
+ is_liquid_variable && (
60
+ is_in_variable_segment ||
61
+ is_on_blank_variable_lookup_position
62
+ )
63
+ )
64
+ end
65
+
66
+ def lookup_liquid_variable(content, cursor)
67
+ return unless cursor_is_on_liquid_variable_lookup_position(content, cursor)
68
+ start_index = content.match(/#{Liquid::VariableStart}-?/o).end(0) + 1
69
+ end_index = cursor - 1
70
+
71
+ # We take the following content
72
+ # - start after the first two {{
73
+ # - end at cursor position
74
+ #
75
+ # That way, we'll have a partial liquid variable that
76
+ # can be parsed such that the "last" variable_lookup
77
+ # will be the one we're trying to complete.
78
+ markup = content[start_index..end_index]
79
+
80
+ # Early return for incomplete variables
81
+ return empty_lookup if markup =~ /\s+$/
82
+
83
+ # Now we go to hack city... The cursor might be in the middle
84
+ # of a string/square bracket lookup. We need to close those
85
+ # otherwise the variable parse won't work.
86
+ markup += "'" if markup.count("'").odd?
87
+ markup += '"' if markup.count('"').odd?
88
+ markup += "]" if markup =~ UNCLOSED_SQUARE_BRACKET
89
+
90
+ variable = variable_from_markup(markup)
91
+
92
+ variable_lookup_for_liquid_variable(variable)
93
+ end
94
+
95
+ def cursor_is_on_liquid_tag_lookup_position(content, cursor)
96
+ markup = content[0..cursor - 1]
97
+ is_liquid_tag = content.match?(Liquid::TagStart)
98
+ is_in_variable_segment = markup =~ ENDS_WITH_POTENTIAL_LOOKUP
99
+ is_on_blank_variable_lookup_position = markup =~ ENDS_WITH_BLANK_POTENTIAL_LOOKUP
100
+ (
101
+ is_liquid_tag && (
102
+ is_in_variable_segment ||
103
+ is_on_blank_variable_lookup_position
104
+ )
105
+ )
106
+ end
107
+
108
+ # Context:
109
+ #
110
+ # We know full well that the code as it is being typed is probably not
111
+ # something that can be parsed by liquid.
112
+ #
113
+ # How this works:
114
+ #
115
+ # 1. Attempt to turn the code of the token until the cursor position into
116
+ # valid liquid code with some hacks.
117
+ # 2. If the code ends in space at a "potential lookup" spot
118
+ # a. Then return an empty variable lookup
119
+ # 3. Parse the valid liquid code
120
+ # 4. Attempt to extract a VariableLookup from Liquid::Template
121
+ def lookup_liquid_tag(content, cursor)
122
+ return unless cursor_is_on_liquid_tag_lookup_position(content, cursor)
123
+
124
+ markup = parseable_markup(content, cursor)
125
+ return empty_lookup if markup == :empty_lookup_markup
126
+
127
+ template = Liquid::Template.parse(markup)
128
+ current_tag = template.root.nodelist[0]
129
+
130
+ case current_tag.tag_name
131
+ when "if", "unless"
132
+ variable_lookup_for_if_tag(current_tag)
133
+ when "case"
134
+ variable_lookup_for_case_tag(current_tag)
135
+ when "cycle"
136
+ variable_lookup_for_cycle_tag(current_tag)
137
+ when "for"
138
+ variable_lookup_for_for_tag(current_tag)
139
+ when "tablerow"
140
+ variable_lookup_for_tablerow_tag(current_tag)
141
+ when "render"
142
+ variable_lookup_for_render_tag(current_tag)
143
+ when "assign"
144
+ variable_lookup_for_assign_tag(current_tag)
145
+ when "echo"
146
+ variable_lookup_for_echo_tag(current_tag)
147
+ end
148
+
149
+ # rubocop:disable Style/RedundantReturn
150
+ rescue Liquid::SyntaxError
151
+ # We don't complete variable for liquid syntax errors
152
+ return
153
+ end
154
+ # rubocop:enable Style/RedundantReturn
155
+
156
+ def parseable_markup(content, cursor)
157
+ start_index = 0
158
+ end_index = cursor - 1
159
+ markup = content[start_index..end_index]
160
+
161
+ # Welcome to Hackcity
162
+ markup += "'" if markup.count("'").odd?
163
+ markup += '"' if markup.count('"').odd?
164
+ markup += "]" if markup =~ UNCLOSED_SQUARE_BRACKET
165
+
166
+ # Now check if it's a liquid tag
167
+ is_liquid_tag = markup =~ tag_regex('liquid')
168
+ ends_with_blank_potential_lookup = markup =~ ENDS_WITH_BLANK_POTENTIAL_LOOKUP
169
+ last_line = markup.rstrip.lines.last
170
+ markup = "{% #{last_line}" if is_liquid_tag
171
+
172
+ # Close the tag
173
+ markup += ' %}'
174
+
175
+ # if statements
176
+ is_if_tag = markup =~ tag_regex('if')
177
+ return :empty_lookup_markup if is_if_tag && ends_with_blank_potential_lookup
178
+ markup += '{% endif %}' if is_if_tag
179
+
180
+ # unless statements
181
+ is_unless_tag = markup =~ tag_regex('unless')
182
+ return :empty_lookup_markup if is_unless_tag && ends_with_blank_potential_lookup
183
+ markup += '{% endunless %}' if is_unless_tag
184
+
185
+ # elsif statements
186
+ is_elsif_tag = markup =~ tag_regex('elsif')
187
+ return :empty_lookup_markup if is_elsif_tag && ends_with_blank_potential_lookup
188
+ markup = '{% if x %}' + markup + '{% endif %}' if is_elsif_tag
189
+
190
+ # case statements
191
+ is_case_tag = markup =~ tag_regex('case')
192
+ return :empty_lookup_markup if is_case_tag && ends_with_blank_potential_lookup
193
+ markup += "{% endcase %}" if is_case_tag
194
+
195
+ # when
196
+ is_when_tag = markup =~ tag_regex('when')
197
+ return :empty_lookup_markup if is_when_tag && ends_with_blank_potential_lookup
198
+ markup = "{% case x %}" + markup + "{% endcase %}" if is_when_tag
199
+
200
+ # for statements
201
+ is_for_tag = markup =~ tag_regex('for')
202
+ return :empty_lookup_markup if is_for_tag && ends_with_blank_potential_lookup
203
+ markup += "{% endfor %}" if is_for_tag
204
+
205
+ # tablerow statements
206
+ is_tablerow_tag = markup =~ tag_regex('tablerow')
207
+ return :empty_lookup_markup if is_tablerow_tag && ends_with_blank_potential_lookup
208
+ markup += "{% endtablerow %}" if is_tablerow_tag
209
+
210
+ markup
211
+ end
212
+
213
+ def variable_lookup_for_if_tag(if_tag)
214
+ condition = if_tag.blocks.last
215
+ variable_lookup_for_condition(condition)
216
+ end
217
+
218
+ def variable_lookup_for_condition(condition)
219
+ return variable_lookup_for_condition(condition.child_condition) if condition.child_condition
220
+ return condition.right if condition.right
221
+ condition.left
222
+ end
223
+
224
+ def variable_lookup_for_case_tag(case_tag)
225
+ return variable_lookup_for_case_block(case_tag.blocks.last) unless case_tag.blocks.empty?
226
+ case_tag.left
227
+ end
228
+
229
+ def variable_lookup_for_case_block(condition)
230
+ condition.right
231
+ end
232
+
233
+ def variable_lookup_for_cycle_tag(cycle_tag)
234
+ cycle_tag.variables.last
235
+ end
236
+
237
+ def variable_lookup_for_for_tag(for_tag)
238
+ for_tag.collection_name
239
+ end
240
+
241
+ def variable_lookup_for_tablerow_tag(tablerow_tag)
242
+ tablerow_tag.collection_name
243
+ end
244
+
245
+ def variable_lookup_for_render_tag(render_tag)
246
+ return empty_lookup if render_tag.raw =~ /:\s*$/
247
+ render_tag.attributes.values.last
248
+ end
249
+
250
+ def variable_lookup_for_assign_tag(assign_tag)
251
+ variable_lookup_for_liquid_variable(assign_tag.from)
252
+ end
253
+
254
+ def variable_lookup_for_echo_tag(echo_tag)
255
+ variable_lookup_for_liquid_variable(echo_tag.variable)
256
+ end
257
+
258
+ def variable_lookup_for_liquid_variable(variable)
259
+ has_filters = !variable.filters.empty?
260
+
261
+ # Can complete after trailing comma or :
262
+ if has_filters && variable.raw =~ /[:,]\s*$/
263
+ empty_lookup
264
+ elsif has_filters
265
+ last_filter_argument(variable.filters)
266
+ elsif variable.name.nil?
267
+ empty_lookup
268
+ else
269
+ variable.name
270
+ end
271
+ end
272
+
273
+ def empty_lookup
274
+ Liquid::VariableLookup.parse('')
275
+ end
276
+
277
+ # We want the last thing in variable.filters which is at most
278
+ # an array that looks like [name, positional_args, hash_arg]
279
+ def last_filter_argument(filters)
280
+ filter = filters.last
281
+ return filter[2].values.last if filter.size == 3
282
+ return filter[1].last if filter.size == 2
283
+ nil
284
+ end
285
+
286
+ def variable_from_markup(markup, parse_context = Liquid::ParseContext.new)
287
+ Liquid::Variable.new(markup, parse_context)
288
+ end
289
+
290
+ def tag_regex(tag)
291
+ ShopifyLiquid::Tag.tag_regex(tag)
292
+ end
293
+ end
294
+ end
295
+ end
@@ -6,6 +6,7 @@ module ThemeCheck
6
6
  extend ChecksTracking
7
7
  include ParsingHelpers
8
8
 
9
+ # TODO: remove this once all regex checks are migrate to HtmlCheck# TODO: remove this once all regex checks are migrate to HtmlCheck
9
10
  TAG = /#{Liquid::TagStart}.*?#{Liquid::TagEnd}/om
10
11
  VARIABLE = /#{Liquid::VariableStart}.*?#{Liquid::VariableEnd}/om
11
12
  START_OR_END_QUOTE = /(^['"])|(['"]$)/
@@ -16,9 +17,5 @@ module ThemeCheck
16
17
  ATTR = /[a-z0-9-]+/i
17
18
  HTML_ATTRIBUTE = /#{ATTR}(?:=#{QUOTED_LIQUID_ATTRIBUTE})?/omix
18
19
  HTML_ATTRIBUTES = /(?:#{HTML_ATTRIBUTE}|\s)*/omix
19
-
20
- def add_offense(message, node: nil, template: node&.template, markup: nil, line_number: nil, &block)
21
- offenses << Offense.new(check: self, message: message, template: template, node: node, markup: markup, line_number: line_number, correction: block)
22
- end
23
20
  end
24
21
  end
@@ -125,5 +125,17 @@ module ThemeCheck
125
125
  start = template.full_line(line_number).index(markup)
126
126
  [start, start + markup.length - 1]
127
127
  end
128
+
129
+ def position
130
+ @position ||= Position.new(markup, template&.source, line_number)
131
+ end
132
+
133
+ def start_index
134
+ position.start_index
135
+ end
136
+
137
+ def end_index
138
+ position.end_index
139
+ end
128
140
  end
129
141
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
  module ThemeCheck
3
- Position = Struct.new(:line, :column)
4
-
5
3
  class Offense
6
4
  MAX_SOURCE_EXCERPT_SIZE = 120
7
5
 
@@ -20,6 +18,7 @@ module ThemeCheck
20
18
  end
21
19
 
22
20
  @node = node
21
+ @template = nil
23
22
  if node
24
23
  @template = node.template
25
24
  elsif template
@@ -40,8 +39,7 @@ module ThemeCheck
40
39
  @node.line_number
41
40
  end
42
41
 
43
- @start_position = nil
44
- @end_position = nil
42
+ @position = Position.new(@markup, @template&.source, @line_number)
45
43
  end
46
44
 
47
45
  def source_excerpt
@@ -56,20 +54,28 @@ module ThemeCheck
56
54
  end
57
55
  end
58
56
 
57
+ def start_index
58
+ @position.start_index
59
+ end
60
+
59
61
  def start_line
60
- start_position.line
62
+ @position.start_row
61
63
  end
62
64
 
63
65
  def start_column
64
- start_position.column
66
+ @position.start_column
67
+ end
68
+
69
+ def end_index
70
+ @position.end_index
65
71
  end
66
72
 
67
73
  def end_line
68
- end_position.line
74
+ @position.end_row
69
75
  end
70
76
 
71
77
  def end_column
72
- end_position.column
78
+ @position.end_column
73
79
  end
74
80
 
75
81
  def code_name
@@ -108,52 +114,30 @@ module ThemeCheck
108
114
  end
109
115
  end
110
116
 
111
- def to_s
112
- if template
113
- "#{message} at #{location}"
114
- else
115
- message
116
- end
117
- end
118
-
119
- private
120
-
121
- def full_line(line)
122
- # Liquid::Template is 1-indexed.
123
- template.full_line(line + 1)
117
+ def whole_theme?
118
+ check.whole_theme?
124
119
  end
125
120
 
126
- def lines_of_content
127
- @lines ||= markup.lines.map { |x| x.sub(/\n$/, '') }
121
+ def single_file?
122
+ check.single_file?
128
123
  end
129
124
 
130
- # 0-indexed, inclusive
131
- def start_position
132
- return @start_position if @start_position
133
- return @start_position = Position.new(0, 0) unless line_number && markup
134
-
135
- position = Position.new
136
- position.line = line_number - 1
137
- position.column = full_line(position.line).index(lines_of_content.first) || 0
138
-
139
- @start_position = position
125
+ def ==(other)
126
+ other.is_a?(Offense) &&
127
+ check == other.check &&
128
+ message == other.message &&
129
+ template == other.template &&
130
+ node == other.node &&
131
+ markup == other.markup &&
132
+ line_number == other.line_number
140
133
  end
141
134
 
142
- # 0-indexed, exclusive. It's the line + col that are exclusive.
143
- # This is why it doesn't make sense to calculate them separately.
144
- def end_position
145
- return @end_position if @end_position
146
- return @end_position = Position.new(0, 0) unless line_number && markup
147
-
148
- position = Position.new
149
- position.line = start_line + lines_of_content.size - 1
150
- position.column = if start_line == position.line
151
- start_column + markup.size
135
+ def to_s
136
+ if template
137
+ "#{message} at #{location}"
152
138
  else
153
- lines_of_content.last.size
139
+ message
154
140
  end
155
-
156
- @end_position = position
157
141
  end
158
142
  end
159
143
  end