theme-check 0.8.1 → 0.10.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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/theme-check.yml +3 -0
  3. data/CHANGELOG.md +39 -0
  4. data/CONTRIBUTING.md +2 -1
  5. data/README.md +4 -1
  6. data/RELEASING.md +5 -3
  7. data/config/default.yml +46 -1
  8. data/data/shopify_liquid/tags.yml +3 -0
  9. data/docs/checks/asset_url_filters.md +56 -0
  10. data/docs/checks/content_for_header_modification.md +42 -0
  11. data/docs/checks/img_lazy_loading.md +61 -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 +22 -0
  15. data/exe/theme-check-language-server +1 -2
  16. data/lib/theme_check.rb +9 -1
  17. data/lib/theme_check/analyzer.rb +72 -16
  18. data/lib/theme_check/bug.rb +20 -0
  19. data/lib/theme_check/check.rb +31 -6
  20. data/lib/theme_check/checks.rb +49 -4
  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_lazy_loading.rb +25 -0
  24. data/lib/theme_check/checks/img_width_and_height.rb +18 -49
  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 +5 -5
  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 +6 -4
  35. data/lib/theme_check/disabled_checks.rb +25 -9
  36. data/lib/theme_check/html_check.rb +7 -0
  37. data/lib/theme_check/html_node.rb +56 -0
  38. data/lib/theme_check/html_visitor.rb +38 -0
  39. data/lib/theme_check/json_file.rb +8 -0
  40. data/lib/theme_check/language_server.rb +2 -0
  41. data/lib/theme_check/language_server/completion_providers/filter_completion_provider.rb +1 -0
  42. data/lib/theme_check/language_server/completion_providers/object_completion_provider.rb +10 -8
  43. data/lib/theme_check/language_server/diagnostics_tracker.rb +64 -0
  44. data/lib/theme_check/language_server/handler.rb +31 -26
  45. data/lib/theme_check/language_server/server.rb +1 -1
  46. data/lib/theme_check/language_server/variable_lookup_finder.rb +295 -0
  47. data/lib/theme_check/liquid_check.rb +1 -4
  48. data/lib/theme_check/offense.rb +18 -0
  49. data/lib/theme_check/shopify_liquid/tag.rb +13 -0
  50. data/lib/theme_check/template.rb +8 -0
  51. data/lib/theme_check/theme.rb +7 -2
  52. data/lib/theme_check/version.rb +1 -1
  53. data/lib/theme_check/visitor.rb +2 -11
  54. metadata +17 -3
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ class HtmlCheck < Check
5
+ extend ChecksTracking
6
+ end
7
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+ require "forwardable"
3
+
4
+ module ThemeCheck
5
+ class HtmlNode
6
+ extend Forwardable
7
+ attr_reader :template
8
+
9
+ def_delegators :@value, :content, :attributes
10
+
11
+ def initialize(value, template)
12
+ @value = value
13
+ @template = template
14
+ end
15
+
16
+ def literal?
17
+ @value.name == "text"
18
+ end
19
+
20
+ def element?
21
+ @value.element?
22
+ end
23
+
24
+ def children
25
+ @value.children.map { |child| HtmlNode.new(child, template) }
26
+ end
27
+
28
+ def parent
29
+ HtmlNode.new(@value.parent, template)
30
+ end
31
+
32
+ def name
33
+ if @value.name == "#document-fragment"
34
+ "document"
35
+ else
36
+ @value.name
37
+ end
38
+ end
39
+
40
+ def value
41
+ if literal?
42
+ @value.content
43
+ else
44
+ @value
45
+ end
46
+ end
47
+
48
+ def markup
49
+ @value.to_html
50
+ end
51
+
52
+ def line_number
53
+ @value.line
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+ require "nokogumbo"
3
+ require "forwardable"
4
+
5
+ module ThemeCheck
6
+ class HtmlVisitor
7
+ attr_reader :checks
8
+
9
+ def initialize(checks)
10
+ @checks = checks
11
+ end
12
+
13
+ def visit_template(template)
14
+ doc = parse(template)
15
+ visit(HtmlNode.new(doc, template))
16
+ end
17
+
18
+ private
19
+
20
+ def parse(template)
21
+ Nokogiri::HTML5.fragment(template.source)
22
+ end
23
+
24
+ def visit(node)
25
+ call_checks(:on_element, node) if node.element?
26
+ call_checks(:"on_#{node.name}", node)
27
+ node.children.each { |child| visit(child) }
28
+ unless node.literal?
29
+ call_checks(:"after_#{node.name}", node)
30
+ call_checks(:after_element, node) if node.element?
31
+ end
32
+ end
33
+
34
+ def call_checks(method, *args)
35
+ checks.call(method, *args)
36
+ end
37
+ end
38
+ end
@@ -38,6 +38,14 @@ module ThemeCheck
38
38
  relative_path.sub_ext('').to_s
39
39
  end
40
40
 
41
+ def json?
42
+ true
43
+ end
44
+
45
+ def liquid?
46
+ false
47
+ end
48
+
41
49
  private
42
50
 
43
51
  def load!
@@ -4,10 +4,12 @@ require_relative "language_server/constants"
4
4
  require_relative "language_server/handler"
5
5
  require_relative "language_server/server"
6
6
  require_relative "language_server/tokens"
7
+ require_relative "language_server/variable_lookup_finder"
7
8
  require_relative "language_server/completion_helper"
8
9
  require_relative "language_server/completion_provider"
9
10
  require_relative "language_server/completion_engine"
10
11
  require_relative "language_server/document_link_engine"
12
+ require_relative "language_server/diagnostics_tracker"
11
13
 
12
14
  Dir[__dir__ + "/language_server/completion_providers/*.rb"].each do |file|
13
15
  require file
@@ -37,6 +37,7 @@ module ThemeCheck
37
37
  partial_match = matches(content, NAMED_FILTER).find do |match|
38
38
  match.begin(1) <= cursor && cursor < match.end(1) + 1 # including next character
39
39
  end
40
+ return '' if partial_match.nil?
40
41
  partial_match[1]
41
42
  end
42
43
 
@@ -4,18 +4,20 @@ module ThemeCheck
4
4
  module LanguageServer
5
5
  class ObjectCompletionProvider < CompletionProvider
6
6
  def completions(content, cursor)
7
- return [] unless can_complete?(content, cursor)
8
- partial = first_word(content) || ''
7
+ return [] unless (variable_lookup = variable_lookup_at_cursor(content, cursor))
8
+ return [] unless variable_lookup.lookups.empty?
9
+ return [] if content[cursor - 1] == "."
9
10
  ShopifyLiquid::Object.labels
10
- .select { |w| w.start_with?(partial) }
11
+ .select { |w| w.start_with?(partial(variable_lookup)) }
11
12
  .map { |object| object_to_completion(object) }
12
13
  end
13
14
 
14
- def can_complete?(content, cursor)
15
- content.match?(Liquid::VariableStart) && (
16
- cursor_on_first_word?(content, cursor) ||
17
- cursor_on_start_content?(content, cursor, Liquid::VariableStart)
18
- )
15
+ def variable_lookup_at_cursor(content, cursor)
16
+ VariableLookupFinder.lookup(content, cursor)
17
+ end
18
+
19
+ def partial(variable_lookup)
20
+ variable_lookup.name || ''
19
21
  end
20
22
 
21
23
  private
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ module LanguageServer
5
+ class DiagnosticsTracker
6
+ def initialize
7
+ @previously_reported_files = Set.new
8
+ @single_files_offenses = {}
9
+ @first_run = true
10
+ end
11
+
12
+ def first_run?
13
+ @first_run
14
+ end
15
+
16
+ def build_diagnostics(offenses, analyzed_files: nil)
17
+ reported_files = Set.new
18
+ new_single_file_offenses = {}
19
+
20
+ offenses.group_by(&:template).each do |template, template_offenses|
21
+ next unless template
22
+ reported_offenses = template_offenses
23
+ previous_offenses = @single_files_offenses[template.path]
24
+ if analyzed_files.nil? || analyzed_files.include?(template.path)
25
+ # We re-analyzed the file, so we know the template_offenses are update to date.
26
+ reported_single_file_offenses = reported_offenses.select(&:single_file?)
27
+ if reported_single_file_offenses.any?
28
+ new_single_file_offenses[template.path] = reported_single_file_offenses
29
+ end
30
+ elsif previous_offenses
31
+ # Merge in the previous ones, if some
32
+ reported_offenses |= previous_offenses
33
+ end
34
+ yield template.path, reported_offenses
35
+ reported_files << template.path
36
+ end
37
+
38
+ @single_files_offenses.each do |path, _|
39
+ # Already reported above, skip
40
+ next if reported_files.include?(path)
41
+
42
+ if analyzed_files.nil? || analyzed_files.include?(path)
43
+ # We re-analyzed this file, if it was not reported, all offenses in it got fixed
44
+ yield path, []
45
+ new_single_file_offenses[path] = nil
46
+ end
47
+ # NOTE: No need to re-report previous offenses as LSP should keep them around until
48
+ # we clear them.
49
+ reported_files << path
50
+ end
51
+
52
+ # Publish diagnostics with empty array if all issues on a previously reported template
53
+ # have been fixed.
54
+ (@previously_reported_files - reported_files).each do |path|
55
+ yield path, []
56
+ end
57
+
58
+ @previously_reported_files = reported_files
59
+ @single_files_offenses.merge!(new_single_file_offenses)
60
+ @first_run = false
61
+ end
62
+ end
63
+ end
64
+ end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ require "benchmark"
2
3
 
3
4
  module ThemeCheck
4
5
  module LanguageServer
@@ -19,7 +20,7 @@ module ThemeCheck
19
20
 
20
21
  def initialize(server)
21
22
  @server = server
22
- @previously_reported_files = Set.new
23
+ @diagnostics_tracker = DiagnosticsTracker.new
23
24
  end
24
25
 
25
26
  def on_initialize(id, params)
@@ -52,6 +53,7 @@ module ThemeCheck
52
53
  end
53
54
 
54
55
  def on_text_document_did_open(_id, params)
56
+ return unless @diagnostics_tracker.first_run?
55
57
  relative_path = relative_path_from_text_document_uri(params)
56
58
  @storage.write(relative_path, text_document_text(params))
57
59
  analyze_and_send_offenses(text_document_uri(params))
@@ -124,17 +126,32 @@ module ThemeCheck
124
126
  ignored_patterns: config.ignored_patterns
125
127
  )
126
128
  theme = ThemeCheck::Theme.new(storage)
127
-
128
- offenses = analyze(theme, config)
129
- log("Found #{theme.all.size} templates, and #{offenses.size} offenses")
130
- send_diagnostics(offenses)
131
- end
132
-
133
- def analyze(theme, config)
134
129
  analyzer = ThemeCheck::Analyzer.new(theme, config.enabled_checks)
135
- log("Checking #{config.root}")
136
- analyzer.analyze_theme
137
- analyzer.offenses
130
+
131
+ if @diagnostics_tracker.first_run?
132
+ # Analyze the full theme on first run
133
+ log("Checking #{config.root}")
134
+ offenses = nil
135
+ time = Benchmark.measure do
136
+ offenses = analyzer.analyze_theme
137
+ end
138
+ log("Found #{offenses.size} offenses in #{format("%0.2f", time.real)}s")
139
+ send_diagnostics(offenses)
140
+ else
141
+ # Analyze selected files
142
+ relative_path = Pathname.new(@storage.relative_path(absolute_path))
143
+ file = theme[relative_path]
144
+ # Skip if not a theme file
145
+ if file
146
+ log("Checking #{relative_path}")
147
+ offenses = nil
148
+ time = Benchmark.measure do
149
+ offenses = analyzer.analyze_files([file])
150
+ end
151
+ log("Found #{offenses.size} new offenses in #{format("%0.2f", time.real)}s")
152
+ send_diagnostics(offenses, [absolute_path])
153
+ end
154
+ end
138
155
  end
139
156
 
140
157
  def completions(relative_path, line, col)
@@ -145,22 +162,10 @@ module ThemeCheck
145
162
  @document_link_engine.document_links(relative_path)
146
163
  end
147
164
 
148
- def send_diagnostics(offenses)
149
- reported_files = Set.new
150
-
151
- offenses.group_by(&:template).each do |template, template_offenses|
152
- next unless template
153
- send_diagnostic(template.path, template_offenses)
154
- reported_files << template.path
165
+ def send_diagnostics(offenses, analyzed_files = nil)
166
+ @diagnostics_tracker.build_diagnostics(offenses, analyzed_files: analyzed_files) do |path, diagnostic_offenses|
167
+ send_diagnostic(path, diagnostic_offenses)
155
168
  end
156
-
157
- # Publish diagnostics with empty array if all issues on a previously reported template
158
- # have been solved.
159
- (@previously_reported_files - reported_files).each do |path|
160
- send_diagnostic(path, [])
161
- end
162
-
163
- @previously_reported_files = reported_files
164
169
  end
165
170
 
166
171
  def send_diagnostic(path, offenses)
@@ -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