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.
- checksums.yaml +4 -4
- data/.github/workflows/theme-check.yml +4 -0
- data/.rubocop.yml +1 -1
- data/CHANGELOG.md +43 -0
- data/CONTRIBUTING.md +2 -1
- data/README.md +4 -1
- data/RELEASING.md +5 -3
- data/config/default.yml +38 -1
- data/data/shopify_liquid/tags.yml +3 -0
- data/data/shopify_translation_keys.yml +1 -0
- data/dev.yml +1 -1
- data/docs/checks/content_for_header_modification.md +42 -0
- data/docs/checks/nested_snippet.md +1 -1
- data/docs/checks/parser_blocking_script_tag.md +53 -0
- data/docs/checks/space_inside_braces.md +28 -0
- data/exe/theme-check-language-server +1 -2
- data/lib/theme_check.rb +13 -1
- data/lib/theme_check/analyzer.rb +79 -13
- data/lib/theme_check/bug.rb +20 -0
- data/lib/theme_check/check.rb +36 -7
- data/lib/theme_check/checks.rb +47 -8
- data/lib/theme_check/checks/content_for_header_modification.rb +41 -0
- data/lib/theme_check/checks/img_width_and_height.rb +18 -49
- data/lib/theme_check/checks/missing_enable_comment.rb +4 -4
- data/lib/theme_check/checks/missing_template.rb +1 -0
- data/lib/theme_check/checks/nested_snippet.rb +1 -1
- data/lib/theme_check/checks/parser_blocking_javascript.rb +6 -38
- data/lib/theme_check/checks/parser_blocking_script_tag.rb +20 -0
- data/lib/theme_check/checks/space_inside_braces.rb +8 -2
- data/lib/theme_check/checks/template_length.rb +3 -0
- data/lib/theme_check/checks/valid_html_translation.rb +1 -0
- data/lib/theme_check/cli.rb +1 -1
- data/lib/theme_check/config.rb +8 -2
- data/lib/theme_check/disabled_check.rb +41 -0
- data/lib/theme_check/disabled_checks.rb +33 -29
- data/lib/theme_check/exceptions.rb +32 -0
- data/lib/theme_check/html_check.rb +7 -0
- data/lib/theme_check/html_node.rb +52 -0
- data/lib/theme_check/html_visitor.rb +36 -0
- data/lib/theme_check/json_file.rb +13 -1
- data/lib/theme_check/language_server.rb +2 -1
- data/lib/theme_check/language_server/completion_engine.rb +1 -1
- data/lib/theme_check/language_server/completion_providers/filter_completion_provider.rb +1 -0
- data/lib/theme_check/language_server/completion_providers/object_completion_provider.rb +10 -8
- data/lib/theme_check/language_server/constants.rb +5 -1
- data/lib/theme_check/language_server/diagnostics_tracker.rb +64 -0
- data/lib/theme_check/language_server/document_link_engine.rb +2 -2
- data/lib/theme_check/language_server/handler.rb +63 -50
- data/lib/theme_check/language_server/server.rb +1 -1
- data/lib/theme_check/language_server/variable_lookup_finder.rb +295 -0
- data/lib/theme_check/liquid_check.rb +0 -4
- data/lib/theme_check/node.rb +12 -0
- data/lib/theme_check/offense.rb +30 -46
- data/lib/theme_check/parsing_helpers.rb +1 -1
- data/lib/theme_check/position.rb +77 -0
- data/lib/theme_check/position_helper.rb +37 -0
- data/lib/theme_check/remote_asset_file.rb +3 -0
- data/lib/theme_check/shopify_liquid/tag.rb +13 -0
- data/lib/theme_check/template.rb +8 -0
- data/lib/theme_check/theme.rb +7 -2
- data/lib/theme_check/version.rb +1 -1
- data/lib/theme_check/visitor.rb +4 -14
- data/theme-check.gemspec +2 -0
- metadata +18 -5
- 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(
|
|
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/
|
|
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 =
|
|
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
|
|
8
|
-
|
|
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
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
@
|
|
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
|
-
|
|
33
|
-
|
|
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')
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
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.
|
|
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
|