theme-check 0.7.2 → 0.8.3

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/theme-check.yml +1 -0
  3. data/.rubocop.yml +1 -1
  4. data/CHANGELOG.md +33 -0
  5. data/RELEASING.md +5 -3
  6. data/config/default.yml +1 -1
  7. data/data/shopify_liquid/tags.yml +3 -0
  8. data/data/shopify_translation_keys.yml +1 -0
  9. data/dev.yml +1 -1
  10. data/docs/checks/nested_snippet.md +1 -1
  11. data/docs/checks/space_inside_braces.md +28 -0
  12. data/exe/theme-check +1 -1
  13. data/lib/theme_check.rb +5 -0
  14. data/lib/theme_check/analyzer.rb +19 -9
  15. data/lib/theme_check/bug.rb +20 -0
  16. data/lib/theme_check/check.rb +5 -1
  17. data/lib/theme_check/checks.rb +39 -8
  18. data/lib/theme_check/checks/missing_enable_comment.rb +4 -4
  19. data/lib/theme_check/checks/nested_snippet.rb +1 -1
  20. data/lib/theme_check/checks/space_inside_braces.rb +8 -2
  21. data/lib/theme_check/cli.rb +99 -64
  22. data/lib/theme_check/config.rb +6 -2
  23. data/lib/theme_check/disabled_check.rb +39 -0
  24. data/lib/theme_check/disabled_checks.rb +20 -32
  25. data/lib/theme_check/exceptions.rb +32 -0
  26. data/lib/theme_check/json_file.rb +5 -1
  27. data/lib/theme_check/language_server.rb +1 -1
  28. data/lib/theme_check/language_server/completion_engine.rb +1 -1
  29. data/lib/theme_check/language_server/completion_providers/object_completion_provider.rb +10 -8
  30. data/lib/theme_check/language_server/constants.rb +5 -1
  31. data/lib/theme_check/language_server/document_link_engine.rb +2 -2
  32. data/lib/theme_check/language_server/handler.rb +32 -24
  33. data/lib/theme_check/language_server/variable_lookup_finder.rb +295 -0
  34. data/lib/theme_check/node.rb +12 -0
  35. data/lib/theme_check/offense.rb +14 -48
  36. data/lib/theme_check/parsing_helpers.rb +1 -1
  37. data/lib/theme_check/position.rb +77 -0
  38. data/lib/theme_check/position_helper.rb +37 -0
  39. data/lib/theme_check/remote_asset_file.rb +3 -0
  40. data/lib/theme_check/shopify_liquid/tag.rb +13 -0
  41. data/lib/theme_check/version.rb +1 -1
  42. data/lib/theme_check/visitor.rb +9 -10
  43. data/theme-check.gemspec +2 -0
  44. metadata +10 -5
  45. data/lib/theme_check/language_server/position_helper.rb +0 -27
@@ -91,7 +91,11 @@ module ThemeCheck
91
91
 
92
92
  options_for_check = options.transform_keys(&:to_sym)
93
93
  options_for_check.delete(:enabled)
94
- check = check_class.new(**options_for_check)
94
+ check = if options_for_check.empty?
95
+ check_class.new
96
+ else
97
+ check_class.new(**options_for_check)
98
+ end
95
99
  check.options = options_for_check
96
100
  check
97
101
  end.compact
@@ -104,7 +108,7 @@ module ThemeCheck
104
108
  private
105
109
 
106
110
  def check_name?(name)
107
- name.start_with?(/[A-Z]/)
111
+ name.to_s.start_with?(/[A-Z]/)
108
112
  end
109
113
 
110
114
  def validate_configuration(configuration, default_configuration = self.class.default, parent_keys = [])
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This class keeps track of checks being turned on and off in ranges.
4
+ # We'll use the node position to figure out if the test is disabled or not.
5
+ module ThemeCheck
6
+ class DisabledCheck
7
+ attr_reader :name, :ranges
8
+ attr_accessor :first_line
9
+
10
+ def initialize(name)
11
+ @name = name
12
+ @ranges = []
13
+ @first_line = false
14
+ end
15
+
16
+ def start_index=(index)
17
+ return unless ranges.empty? || !last.end.nil?
18
+ @ranges << (index..)
19
+ end
20
+
21
+ def end_index=(index)
22
+ return if ranges.empty? || !last.end.nil?
23
+ @ranges << (@ranges.pop.begin..index)
24
+ end
25
+
26
+ def disabled?(index)
27
+ ranges.any? { |range| range.cover?(index) }
28
+ end
29
+
30
+ def last
31
+ ranges.last
32
+ end
33
+
34
+ def missing_end_index?
35
+ return false if first_line && ranges.size == 1
36
+ last.end.nil?
37
+ end
38
+ end
39
+ end
@@ -8,50 +8,36 @@ module ThemeCheck
8
8
 
9
9
  ACTION_DISABLE_CHECKS = :disable
10
10
  ACTION_ENABLE_CHECKS = :enable
11
- ACTION_UNRELATED_COMMENT = :unrelated
12
11
 
13
12
  def initialize
14
- @disabled = []
15
- @all_disabled = false
16
- @full_document_disabled = false
13
+ @disabled_checks = {}
17
14
  end
18
15
 
19
16
  def update(node)
20
17
  text = comment_text(node)
21
-
22
18
  if start_disabling?(text)
23
- @disabled = checks_from_text(text)
24
- @all_disabled = @disabled.empty?
25
-
26
- if node&.line_number == 1
27
- @full_document_disabled = true
19
+ checks_from_text(text).each do |check_name|
20
+ @disabled_checks[check_name] ||= DisabledCheck.new(check_name)
21
+ @disabled_checks[check_name].start_index = node.start_index
22
+ @disabled_checks[check_name].first_line = true if node.line_number == 1
28
23
  end
29
24
  elsif stop_disabling?(text)
30
- checks = checks_from_text(text)
31
- @disabled = checks.empty? ? [] : @disabled - checks
32
-
33
- @all_disabled = false
25
+ checks_from_text(text).each do |check_name|
26
+ next unless @disabled_checks.key?(check_name)
27
+ @disabled_checks[check_name].end_index = node.end_index
28
+ end
34
29
  end
35
30
  end
36
31
 
37
- # Whether any checks are currently disabled
38
- def any?
39
- !@disabled.empty? || @all_disabled
40
- end
41
-
42
- # Whether all checks should be disabled
43
- def all_disabled?
44
- @all_disabled
45
- end
46
-
47
- # Get a list of all the individual disabled checks
48
- def all
49
- @disabled
32
+ def disabled?(key, index)
33
+ @disabled_checks[:all]&.disabled?(index) ||
34
+ @disabled_checks[key]&.disabled?(index)
50
35
  end
51
36
 
52
- # If the first line of the document is a theme-check-disable comment
53
- def full_document_disabled?
54
- @full_document_disabled
37
+ def checks_missing_end_index
38
+ @disabled_checks.values
39
+ .select(&:missing_end_index?)
40
+ .map(&:name)
55
41
  end
56
42
 
57
43
  private
@@ -69,9 +55,11 @@ module ThemeCheck
69
55
  end
70
56
 
71
57
  # Return a list of checks from a theme-check-disable comment
72
- # Returns [] if all checks are meant to be disabled
58
+ # Returns [:all] if all checks are meant to be disabled
73
59
  def checks_from_text(text)
74
- text.gsub(DISABLE_PREFIX_PATTERN, '').strip.split(',').map(&:strip)
60
+ checks = text.gsub(DISABLE_PREFIX_PATTERN, '').strip.split(',').map(&:strip)
61
+ return [:all] if checks.empty?
62
+ checks
75
63
  end
76
64
  end
77
65
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+ require "net/http"
3
+
4
+ TIMEOUT_EXCEPTIONS = [
5
+ Net::ReadTimeout,
6
+ Net::OpenTimeout,
7
+ Net::WriteTimeout,
8
+ Errno::ETIMEDOUT,
9
+ Timeout::Error,
10
+ ]
11
+
12
+ CONNECTION_EXCEPTIONS = [
13
+ IOError,
14
+ EOFError,
15
+ SocketError,
16
+ Errno::EINVAL,
17
+ Errno::ECONNRESET,
18
+ Errno::ECONNABORTED,
19
+ Errno::EPIPE,
20
+ Errno::ECONNREFUSED,
21
+ Errno::EAGAIN,
22
+ Errno::EHOSTUNREACH,
23
+ Errno::ENETUNREACH,
24
+ ]
25
+
26
+ NET_HTTP_EXCEPTIONS = [
27
+ Net::HTTPBadResponse,
28
+ Net::HTTPHeaderSyntaxError,
29
+ Net::ProtocolError,
30
+ *TIMEOUT_EXCEPTIONS,
31
+ *CONNECTION_EXCEPTIONS,
32
+ ]
@@ -20,6 +20,10 @@ module ThemeCheck
20
20
  @relative_pathname ||= Pathname.new(@relative_path)
21
21
  end
22
22
 
23
+ def source
24
+ @source ||= @storage.read(@relative_path)
25
+ end
26
+
23
27
  def content
24
28
  load!
25
29
  @content
@@ -39,7 +43,7 @@ module ThemeCheck
39
43
  def load!
40
44
  return if @loaded
41
45
 
42
- @content = JSON.parse(@storage.read(@relative_path))
46
+ @content = JSON.parse(source)
43
47
  rescue JSON::ParserError => e
44
48
  @parser_error = e
45
49
  ensure
@@ -4,7 +4,7 @@ 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/position_helper"
7
+ require_relative "language_server/variable_lookup_finder"
8
8
  require_relative "language_server/completion_helper"
9
9
  require_relative "language_server/completion_provider"
10
10
  require_relative "language_server/completion_engine"
@@ -12,7 +12,7 @@ module ThemeCheck
12
12
 
13
13
  def completions(relative_path, line, col)
14
14
  buffer = @storage.read(relative_path)
15
- cursor = from_line_column_to_index(buffer, line, col)
15
+ cursor = from_row_column_to_index(buffer, line, col)
16
16
  token = find_token(buffer, cursor)
17
17
  return [] if token.nil?
18
18
 
@@ -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
@@ -4,7 +4,11 @@ module ThemeCheck
4
4
  module LanguageServer
5
5
  PARTIAL_RENDER = %r{
6
6
  \{\%-?\s*render\s+'(?<partial>[^']*)'|
7
- \{\%-?\s*render\s+"(?<partial>[^"]*)"
7
+ \{\%-?\s*render\s+"(?<partial>[^"]*)"|
8
+
9
+ # in liquid tags the whole line is white space until render
10
+ ^\s*render\s+'(?<partial>[^']*)'|
11
+ ^\s*render\s+"(?<partial>[^"]*)"
8
12
  }mix
9
13
  end
10
14
  end
@@ -14,12 +14,12 @@ module ThemeCheck
14
14
  buffer = @storage.read(relative_path)
15
15
  return [] unless buffer
16
16
  matches(buffer, PARTIAL_RENDER).map do |match|
17
- start_line, start_character = from_index_to_line_column(
17
+ start_line, start_character = from_index_to_row_column(
18
18
  buffer,
19
19
  match.begin(:partial),
20
20
  )
21
21
 
22
- end_line, end_character = from_index_to_line_column(
22
+ end_line, end_character = from_index_to_row_column(
23
23
  buffer,
24
24
  match.end(:partial)
25
25
  )
@@ -23,17 +23,17 @@ module ThemeCheck
23
23
  end
24
24
 
25
25
  def on_initialize(id, params)
26
- @root_path = params["rootPath"]
26
+ @root_path = path_from_uri(params["rootUri"]) || params["rootPath"]
27
+
28
+ # Tell the client we don't support anything if there's no rootPath
29
+ return send_response(id, { capabilities: {} }) if @root_path.nil?
27
30
  @storage = in_memory_storage(@root_path)
28
31
  @completion_engine = CompletionEngine.new(@storage)
29
32
  @document_link_engine = DocumentLinkEngine.new(@storage)
30
33
  # https://microsoft.github.io/language-server-protocol/specifications/specification-current/#responseMessage
31
- send_response(
32
- id: id,
33
- result: {
34
- capabilities: CAPABILITIES,
35
- }
36
- )
34
+ send_response(id, {
35
+ capabilities: CAPABILITIES,
36
+ })
37
37
  end
38
38
 
39
39
  def on_exit(_id, _params)
@@ -63,20 +63,14 @@ module ThemeCheck
63
63
 
64
64
  def on_text_document_document_link(id, params)
65
65
  relative_path = relative_path_from_text_document_uri(params)
66
- send_response(
67
- id: id,
68
- result: document_links(relative_path)
69
- )
66
+ send_response(id, document_links(relative_path))
70
67
  end
71
68
 
72
69
  def on_text_document_completion(id, params)
73
70
  relative_path = relative_path_from_text_document_uri(params)
74
71
  line = params.dig('position', 'line')
75
72
  col = params.dig('position', 'character')
76
- send_response(
77
- id: id,
78
- result: completions(relative_path, line, col)
79
- )
73
+ send_response(id, completions(relative_path, line, col))
80
74
  end
81
75
 
82
76
  private
@@ -99,7 +93,11 @@ module ThemeCheck
99
93
  end
100
94
 
101
95
  def text_document_uri(params)
102
- params.dig('textDocument', 'uri').sub('file://', '')
96
+ path_from_uri(params.dig('textDocument', 'uri'))
97
+ end
98
+
99
+ def path_from_uri(uri)
100
+ uri&.sub('file://', '')
103
101
  end
104
102
 
105
103
  def relative_path_from_text_document_uri(params)
@@ -167,13 +165,10 @@ module ThemeCheck
167
165
 
168
166
  def send_diagnostic(path, offenses)
169
167
  # https://microsoft.github.io/language-server-protocol/specifications/specification-current/#notificationMessage
170
- send_response(
171
- method: 'textDocument/publishDiagnostics',
172
- params: {
173
- uri: "file://#{path}",
174
- diagnostics: offenses.map { |offense| offense_to_diagnostic(offense) },
175
- },
176
- )
168
+ send_notification('textDocument/publishDiagnostics', {
169
+ uri: "file://#{path}",
170
+ diagnostics: offenses.map { |offense| offense_to_diagnostic(offense) },
171
+ })
177
172
  end
178
173
 
179
174
  def offense_to_diagnostic(offense)
@@ -220,11 +215,24 @@ module ThemeCheck
220
215
  }
221
216
  end
222
217
 
223
- def send_response(message)
218
+ def send_message(message)
224
219
  message[:jsonrpc] = '2.0'
225
220
  @server.send_response(message)
226
221
  end
227
222
 
223
+ def send_response(id, result = nil, error = nil)
224
+ message = { id: id }
225
+ message[:result] = result if result
226
+ message[:error] = error if error
227
+ send_message(message)
228
+ end
229
+
230
+ def send_notification(method, params)
231
+ message = { method: method }
232
+ message[:params] = params
233
+ send_message(message)
234
+ end
235
+
228
236
  def log(message)
229
237
  @server.log(message)
230
238
  end
@@ -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