theme-check 0.7.3 → 0.9.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/theme-check.yml +4 -0
  3. data/.rubocop.yml +1 -1
  4. data/CHANGELOG.md +43 -0
  5. data/CONTRIBUTING.md +2 -1
  6. data/README.md +4 -1
  7. data/RELEASING.md +5 -3
  8. data/config/default.yml +38 -1
  9. data/data/shopify_liquid/tags.yml +3 -0
  10. data/data/shopify_translation_keys.yml +1 -0
  11. data/dev.yml +1 -1
  12. data/docs/checks/content_for_header_modification.md +42 -0
  13. data/docs/checks/nested_snippet.md +1 -1
  14. data/docs/checks/parser_blocking_script_tag.md +53 -0
  15. data/docs/checks/space_inside_braces.md +28 -0
  16. data/exe/theme-check-language-server +1 -2
  17. data/lib/theme_check.rb +13 -1
  18. data/lib/theme_check/analyzer.rb +79 -13
  19. data/lib/theme_check/bug.rb +20 -0
  20. data/lib/theme_check/check.rb +36 -7
  21. data/lib/theme_check/checks.rb +47 -8
  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/space_inside_braces.rb +8 -2
  30. data/lib/theme_check/checks/template_length.rb +3 -0
  31. data/lib/theme_check/checks/valid_html_translation.rb +1 -0
  32. data/lib/theme_check/cli.rb +1 -1
  33. data/lib/theme_check/config.rb +8 -2
  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 +52 -0
  39. data/lib/theme_check/html_visitor.rb +36 -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 +0 -4
  52. data/lib/theme_check/node.rb +12 -0
  53. data/lib/theme_check/offense.rb +30 -46
  54. data/lib/theme_check/parsing_helpers.rb +1 -1
  55. data/lib/theme_check/position.rb +77 -0
  56. data/lib/theme_check/position_helper.rb +37 -0
  57. data/lib/theme_check/remote_asset_file.rb +3 -0
  58. data/lib/theme_check/shopify_liquid/tag.rb +13 -0
  59. data/lib/theme_check/template.rb +8 -0
  60. data/lib/theme_check/theme.rb +7 -2
  61. data/lib/theme_check/version.rb +1 -1
  62. data/lib/theme_check/visitor.rb +4 -14
  63. data/theme-check.gemspec +2 -0
  64. metadata +18 -5
  65. data/lib/theme_check/language_server/position_helper.rb +0 -27
@@ -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
@@ -34,12 +38,20 @@ module ThemeCheck
34
38
  relative_path.sub_ext('').to_s
35
39
  end
36
40
 
41
+ def json?
42
+ true
43
+ end
44
+
45
+ def liquid?
46
+ false
47
+ end
48
+
37
49
  private
38
50
 
39
51
  def load!
40
52
  return if @loaded
41
53
 
42
- @content = JSON.parse(@storage.read(@relative_path))
54
+ @content = JSON.parse(source)
43
55
  rescue JSON::ParserError => e
44
56
  @parser_error = e
45
57
  ensure
@@ -4,11 +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/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"
11
11
  require_relative "language_server/document_link_engine"
12
+ require_relative "language_server/diagnostics_tracker"
12
13
 
13
14
  Dir[__dir__ + "/language_server/completion_providers/*.rb"].each do |file|
14
15
  require file
@@ -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
 
@@ -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
@@ -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
@@ -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
@@ -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
  )
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ require "benchmark"
2
3
 
3
4
  module ThemeCheck
4
5
  module LanguageServer
@@ -19,21 +20,21 @@ 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)
26
- @root_path = params["rootPath"]
27
+ @root_path = path_from_uri(params["rootUri"]) || params["rootPath"]
28
+
29
+ # Tell the client we don't support anything if there's no rootPath
30
+ return send_response(id, { capabilities: {} }) if @root_path.nil?
27
31
  @storage = in_memory_storage(@root_path)
28
32
  @completion_engine = CompletionEngine.new(@storage)
29
33
  @document_link_engine = DocumentLinkEngine.new(@storage)
30
34
  # https://microsoft.github.io/language-server-protocol/specifications/specification-current/#responseMessage
31
- send_response(
32
- id: id,
33
- result: {
34
- capabilities: CAPABILITIES,
35
- }
36
- )
35
+ send_response(id, {
36
+ capabilities: CAPABILITIES,
37
+ })
37
38
  end
38
39
 
39
40
  def on_exit(_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))
@@ -63,20 +65,14 @@ module ThemeCheck
63
65
 
64
66
  def on_text_document_document_link(id, params)
65
67
  relative_path = relative_path_from_text_document_uri(params)
66
- send_response(
67
- id: id,
68
- result: document_links(relative_path)
69
- )
68
+ send_response(id, document_links(relative_path))
70
69
  end
71
70
 
72
71
  def on_text_document_completion(id, params)
73
72
  relative_path = relative_path_from_text_document_uri(params)
74
73
  line = params.dig('position', 'line')
75
74
  col = params.dig('position', 'character')
76
- send_response(
77
- id: id,
78
- result: completions(relative_path, line, col)
79
- )
75
+ send_response(id, completions(relative_path, line, col))
80
76
  end
81
77
 
82
78
  private
@@ -99,7 +95,11 @@ module ThemeCheck
99
95
  end
100
96
 
101
97
  def text_document_uri(params)
102
- params.dig('textDocument', 'uri').sub('file://', '')
98
+ path_from_uri(params.dig('textDocument', 'uri'))
99
+ end
100
+
101
+ def path_from_uri(uri)
102
+ uri&.sub('file://', '')
103
103
  end
104
104
 
105
105
  def relative_path_from_text_document_uri(params)
@@ -126,17 +126,32 @@ module ThemeCheck
126
126
  ignored_patterns: config.ignored_patterns
127
127
  )
128
128
  theme = ThemeCheck::Theme.new(storage)
129
-
130
- offenses = analyze(theme, config)
131
- log("Found #{theme.all.size} templates, and #{offenses.size} offenses")
132
- send_diagnostics(offenses)
133
- end
134
-
135
- def analyze(theme, config)
136
129
  analyzer = ThemeCheck::Analyzer.new(theme, config.enabled_checks)
137
- log("Checking #{config.root}")
138
- analyzer.analyze_theme
139
- 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
140
155
  end
141
156
 
142
157
  def completions(relative_path, line, col)
@@ -147,33 +162,18 @@ module ThemeCheck
147
162
  @document_link_engine.document_links(relative_path)
148
163
  end
149
164
 
150
- def send_diagnostics(offenses)
151
- reported_files = Set.new
152
-
153
- offenses.group_by(&:template).each do |template, template_offenses|
154
- next unless template
155
- send_diagnostic(template.path, template_offenses)
156
- reported_files << template.path
157
- end
158
-
159
- # Publish diagnostics with empty array if all issues on a previously reported template
160
- # have been solved.
161
- (@previously_reported_files - reported_files).each do |path|
162
- send_diagnostic(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)
163
168
  end
164
-
165
- @previously_reported_files = reported_files
166
169
  end
167
170
 
168
171
  def send_diagnostic(path, offenses)
169
172
  # 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
- )
173
+ send_notification('textDocument/publishDiagnostics', {
174
+ uri: "file://#{path}",
175
+ diagnostics: offenses.map { |offense| offense_to_diagnostic(offense) },
176
+ })
177
177
  end
178
178
 
179
179
  def offense_to_diagnostic(offense)
@@ -220,11 +220,24 @@ module ThemeCheck
220
220
  }
221
221
  end
222
222
 
223
- def send_response(message)
223
+ def send_message(message)
224
224
  message[:jsonrpc] = '2.0'
225
225
  @server.send_response(message)
226
226
  end
227
227
 
228
+ def send_response(id, result = nil, error = nil)
229
+ message = { id: id }
230
+ message[:result] = result if result
231
+ message[:error] = error if error
232
+ send_message(message)
233
+ end
234
+
235
+ def send_notification(method, params)
236
+ message = { method: method }
237
+ message[:params] = params
238
+ send_message(message)
239
+ end
240
+
228
241
  def log(message)
229
242
  @server.log(message)
230
243
  end
@@ -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