theme-check 0.7.3 → 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
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