theme-check 1.6.2 → 1.8.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/CHANGELOG.md +37 -0
- data/data/shopify_liquid/filters.yml +1 -0
- data/data/shopify_liquid/tags.yml +9 -9
- data/docs/checks/TEMPLATE.md.erb +24 -19
- data/exe/theme-check-language-server +0 -4
- data/lib/theme_check/analyzer.rb +29 -5
- data/lib/theme_check/checks/matching_schema_translations.rb +12 -5
- data/lib/theme_check/checks/required_layout_theme_object.rb +9 -4
- data/lib/theme_check/checks/translation_key_exists.rb +1 -13
- data/lib/theme_check/checks/unused_assign.rb +3 -2
- data/lib/theme_check/checks/unused_snippet.rb +1 -1
- data/lib/theme_check/corrector.rb +40 -3
- data/lib/theme_check/exceptions.rb +1 -0
- data/lib/theme_check/file_system_storage.rb +4 -0
- data/lib/theme_check/language_server/bridge.rb +142 -0
- data/lib/theme_check/language_server/channel.rb +69 -0
- data/lib/theme_check/language_server/completion_providers/tag_completion_provider.rb +3 -1
- data/lib/theme_check/language_server/diagnostics_engine.rb +125 -0
- data/lib/theme_check/language_server/handler.rb +24 -118
- data/lib/theme_check/language_server/io_messenger.rb +104 -0
- data/lib/theme_check/language_server/messenger.rb +27 -0
- data/lib/theme_check/language_server/protocol.rb +4 -0
- data/lib/theme_check/language_server/server.rb +111 -103
- data/lib/theme_check/language_server.rb +6 -1
- data/lib/theme_check/liquid_node.rb +33 -0
- data/lib/theme_check/locale_diff.rb +36 -10
- data/lib/theme_check/position.rb +4 -4
- data/lib/theme_check/shopify_liquid/system_translations.rb +35 -0
- data/lib/theme_check/shopify_liquid/tag.rb +19 -1
- data/lib/theme_check/shopify_liquid.rb +1 -0
- data/lib/theme_check/tags.rb +0 -1
- data/lib/theme_check/theme_file_rewriter.rb +13 -0
- data/lib/theme_check/version.rb +1 -1
- data/lib/theme_check.rb +4 -0
- metadata +8 -2
@@ -6,7 +6,9 @@ module ThemeCheck
|
|
6
6
|
def completions(content, cursor)
|
7
7
|
return [] unless can_complete?(content, cursor)
|
8
8
|
partial = first_word(content) || ''
|
9
|
-
ShopifyLiquid::Tag.labels
|
9
|
+
labels = ShopifyLiquid::Tag.labels
|
10
|
+
labels += ShopifyLiquid::Tag.end_labels
|
11
|
+
labels
|
10
12
|
.select { |w| w.start_with?(partial) }
|
11
13
|
.map { |tag| tag_to_completion(tag) }
|
12
14
|
end
|
@@ -0,0 +1,125 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ThemeCheck
|
4
|
+
module LanguageServer
|
5
|
+
class DiagnosticsEngine
|
6
|
+
include URIHelper
|
7
|
+
|
8
|
+
def initialize(bridge)
|
9
|
+
@diagnostics_lock = Mutex.new
|
10
|
+
@diagnostics_tracker = DiagnosticsTracker.new
|
11
|
+
@bridge = bridge
|
12
|
+
@token = 0
|
13
|
+
end
|
14
|
+
|
15
|
+
def first_run?
|
16
|
+
@diagnostics_tracker.first_run?
|
17
|
+
end
|
18
|
+
|
19
|
+
def analyze_and_send_offenses(absolute_path, config)
|
20
|
+
return unless @diagnostics_lock.try_lock
|
21
|
+
@token += 1
|
22
|
+
@bridge.send_create_work_done_progress_request(@token)
|
23
|
+
storage = ThemeCheck::FileSystemStorage.new(
|
24
|
+
config.root,
|
25
|
+
ignored_patterns: config.ignored_patterns
|
26
|
+
)
|
27
|
+
theme = ThemeCheck::Theme.new(storage)
|
28
|
+
analyzer = ThemeCheck::Analyzer.new(theme, config.enabled_checks)
|
29
|
+
|
30
|
+
if @diagnostics_tracker.first_run?
|
31
|
+
@bridge.send_work_done_progress_begin(@token, "Full theme check")
|
32
|
+
@bridge.log("Checking #{config.root}")
|
33
|
+
offenses = nil
|
34
|
+
time = Benchmark.measure do
|
35
|
+
offenses = analyzer.analyze_theme do |path, i, total|
|
36
|
+
@bridge.send_work_done_progress_report(@token, "#{i}/#{total} #{path}", (i.to_f / total * 100.0).to_i)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end_message = "Found #{offenses.size} offenses in #{format("%0.2f", time.real)}s"
|
40
|
+
@bridge.send_work_done_progress_end(@token, end_message)
|
41
|
+
send_diagnostics(offenses)
|
42
|
+
else
|
43
|
+
# Analyze selected files
|
44
|
+
relative_path = Pathname.new(storage.relative_path(absolute_path))
|
45
|
+
file = theme[relative_path]
|
46
|
+
# Skip if not a theme file
|
47
|
+
if file
|
48
|
+
@bridge.send_work_done_progress_begin(@token, "Partial theme check")
|
49
|
+
offenses = nil
|
50
|
+
time = Benchmark.measure do
|
51
|
+
offenses = analyzer.analyze_files([file]) do |path, i, total|
|
52
|
+
@bridge.send_work_done_progress_report(@token, "#{i}/#{total} #{path}", (i.to_f / total * 100.0).to_i)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end_message = "Found #{offenses.size} new offenses in #{format("%0.2f", time.real)}s"
|
56
|
+
@bridge.send_work_done_progress_end(@token, end_message)
|
57
|
+
@bridge.log(end_message)
|
58
|
+
send_diagnostics(offenses, [absolute_path])
|
59
|
+
end
|
60
|
+
end
|
61
|
+
@diagnostics_lock.unlock
|
62
|
+
end
|
63
|
+
|
64
|
+
private
|
65
|
+
|
66
|
+
def send_diagnostics(offenses, analyzed_files = nil)
|
67
|
+
@diagnostics_tracker.build_diagnostics(offenses, analyzed_files: analyzed_files) do |path, diagnostic_offenses|
|
68
|
+
send_diagnostic(path, diagnostic_offenses)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def send_diagnostic(path, offenses)
|
73
|
+
# https://microsoft.github.io/language-server-protocol/specifications/specification-current/#notificationMessage
|
74
|
+
@bridge.send_notification('textDocument/publishDiagnostics', {
|
75
|
+
uri: file_uri(path),
|
76
|
+
diagnostics: offenses.map { |offense| offense_to_diagnostic(offense) },
|
77
|
+
})
|
78
|
+
end
|
79
|
+
|
80
|
+
def offense_to_diagnostic(offense)
|
81
|
+
diagnostic = {
|
82
|
+
code: offense.code_name,
|
83
|
+
message: offense.message,
|
84
|
+
range: range(offense),
|
85
|
+
severity: severity(offense),
|
86
|
+
source: "theme-check",
|
87
|
+
}
|
88
|
+
diagnostic["codeDescription"] = code_description(offense) unless offense.doc.nil?
|
89
|
+
diagnostic
|
90
|
+
end
|
91
|
+
|
92
|
+
def code_description(offense)
|
93
|
+
{
|
94
|
+
href: offense.doc,
|
95
|
+
}
|
96
|
+
end
|
97
|
+
|
98
|
+
def severity(offense)
|
99
|
+
case offense.severity
|
100
|
+
when :error
|
101
|
+
1
|
102
|
+
when :suggestion
|
103
|
+
2
|
104
|
+
when :style
|
105
|
+
3
|
106
|
+
else
|
107
|
+
4
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
def range(offense)
|
112
|
+
{
|
113
|
+
start: {
|
114
|
+
line: offense.start_line,
|
115
|
+
character: offense.start_column,
|
116
|
+
},
|
117
|
+
end: {
|
118
|
+
line: offense.end_line,
|
119
|
+
character: offense.end_column,
|
120
|
+
},
|
121
|
+
}
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
@@ -7,6 +7,11 @@ module ThemeCheck
|
|
7
7
|
class Handler
|
8
8
|
include URIHelper
|
9
9
|
|
10
|
+
SERVER_INFO = {
|
11
|
+
name: $PROGRAM_NAME,
|
12
|
+
version: ThemeCheck::VERSION,
|
13
|
+
}
|
14
|
+
|
10
15
|
CAPABILITIES = {
|
11
16
|
completionProvider: {
|
12
17
|
triggerCharacters: ['.', '{{ ', '{% '],
|
@@ -21,29 +26,34 @@ module ThemeCheck
|
|
21
26
|
},
|
22
27
|
}
|
23
28
|
|
24
|
-
def initialize(
|
25
|
-
@
|
26
|
-
@diagnostics_tracker = DiagnosticsTracker.new
|
29
|
+
def initialize(bridge)
|
30
|
+
@bridge = bridge
|
27
31
|
end
|
28
32
|
|
29
33
|
def on_initialize(id, params)
|
30
34
|
@root_path = root_path_from_params(params)
|
31
35
|
|
32
36
|
# Tell the client we don't support anything if there's no rootPath
|
33
|
-
return send_response(id, { capabilities: {} }) if @root_path.nil?
|
37
|
+
return @bridge.send_response(id, { capabilities: {} }) if @root_path.nil?
|
38
|
+
|
39
|
+
@bridge.supports_work_done_progress = params.dig('capabilities', 'window', 'workDoneProgress') || false
|
34
40
|
@storage = in_memory_storage(@root_path)
|
35
41
|
@completion_engine = CompletionEngine.new(@storage)
|
36
42
|
@document_link_engine = DocumentLinkEngine.new(@storage)
|
37
|
-
|
38
|
-
send_response(id, {
|
43
|
+
@diagnostics_engine = DiagnosticsEngine.new(@bridge)
|
44
|
+
@bridge.send_response(id, {
|
39
45
|
capabilities: CAPABILITIES,
|
46
|
+
serverInfo: SERVER_INFO,
|
40
47
|
})
|
41
48
|
end
|
42
49
|
|
50
|
+
def on_shutdown(id, _params)
|
51
|
+
@bridge.send_response(id, nil)
|
52
|
+
end
|
53
|
+
|
43
54
|
def on_exit(_id, _params)
|
44
55
|
close!
|
45
56
|
end
|
46
|
-
alias_method :on_shutdown, :on_exit
|
47
57
|
|
48
58
|
def on_text_document_did_change(_id, params)
|
49
59
|
relative_path = relative_path_from_text_document_uri(params)
|
@@ -58,7 +68,7 @@ module ThemeCheck
|
|
58
68
|
def on_text_document_did_open(_id, params)
|
59
69
|
relative_path = relative_path_from_text_document_uri(params)
|
60
70
|
@storage.write(relative_path, text_document_text(params))
|
61
|
-
analyze_and_send_offenses(text_document_uri(params)) if @
|
71
|
+
analyze_and_send_offenses(text_document_uri(params)) if @diagnostics_engine.first_run?
|
62
72
|
end
|
63
73
|
|
64
74
|
def on_text_document_did_save(_id, params)
|
@@ -67,14 +77,14 @@ module ThemeCheck
|
|
67
77
|
|
68
78
|
def on_text_document_document_link(id, params)
|
69
79
|
relative_path = relative_path_from_text_document_uri(params)
|
70
|
-
send_response(id, document_links(relative_path))
|
80
|
+
@bridge.send_response(id, document_links(relative_path))
|
71
81
|
end
|
72
82
|
|
73
83
|
def on_text_document_completion(id, params)
|
74
84
|
relative_path = relative_path_from_text_document_uri(params)
|
75
85
|
line = params.dig('position', 'line')
|
76
86
|
col = params.dig('position', 'character')
|
77
|
-
send_response(id, completions(relative_path, line, col))
|
87
|
+
@bridge.send_response(id, completions(relative_path, line, col))
|
78
88
|
end
|
79
89
|
|
80
90
|
private
|
@@ -128,38 +138,10 @@ module ThemeCheck
|
|
128
138
|
end
|
129
139
|
|
130
140
|
def analyze_and_send_offenses(absolute_path)
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
ignored_patterns: config.ignored_patterns
|
141
|
+
@diagnostics_engine.analyze_and_send_offenses(
|
142
|
+
absolute_path,
|
143
|
+
config_for_path(absolute_path)
|
135
144
|
)
|
136
|
-
theme = ThemeCheck::Theme.new(storage)
|
137
|
-
analyzer = ThemeCheck::Analyzer.new(theme, config.enabled_checks)
|
138
|
-
|
139
|
-
if @diagnostics_tracker.first_run?
|
140
|
-
# Analyze the full theme on first run
|
141
|
-
log("Checking #{config.root}")
|
142
|
-
offenses = nil
|
143
|
-
time = Benchmark.measure do
|
144
|
-
offenses = analyzer.analyze_theme
|
145
|
-
end
|
146
|
-
log("Found #{offenses.size} offenses in #{format("%0.2f", time.real)}s")
|
147
|
-
send_diagnostics(offenses)
|
148
|
-
else
|
149
|
-
# Analyze selected files
|
150
|
-
relative_path = Pathname.new(@storage.relative_path(absolute_path))
|
151
|
-
file = theme[relative_path]
|
152
|
-
# Skip if not a theme file
|
153
|
-
if file
|
154
|
-
log("Checking #{relative_path}")
|
155
|
-
offenses = nil
|
156
|
-
time = Benchmark.measure do
|
157
|
-
offenses = analyzer.analyze_files([file])
|
158
|
-
end
|
159
|
-
log("Found #{offenses.size} new offenses in #{format("%0.2f", time.real)}s")
|
160
|
-
send_diagnostics(offenses, [absolute_path])
|
161
|
-
end
|
162
|
-
end
|
163
145
|
end
|
164
146
|
|
165
147
|
def completions(relative_path, line, col)
|
@@ -170,84 +152,8 @@ module ThemeCheck
|
|
170
152
|
@document_link_engine.document_links(relative_path)
|
171
153
|
end
|
172
154
|
|
173
|
-
def send_diagnostics(offenses, analyzed_files = nil)
|
174
|
-
@diagnostics_tracker.build_diagnostics(offenses, analyzed_files: analyzed_files) do |path, diagnostic_offenses|
|
175
|
-
send_diagnostic(path, diagnostic_offenses)
|
176
|
-
end
|
177
|
-
end
|
178
|
-
|
179
|
-
def send_diagnostic(path, offenses)
|
180
|
-
# https://microsoft.github.io/language-server-protocol/specifications/specification-current/#notificationMessage
|
181
|
-
send_notification('textDocument/publishDiagnostics', {
|
182
|
-
uri: file_uri(path),
|
183
|
-
diagnostics: offenses.map { |offense| offense_to_diagnostic(offense) },
|
184
|
-
})
|
185
|
-
end
|
186
|
-
|
187
|
-
def offense_to_diagnostic(offense)
|
188
|
-
diagnostic = {
|
189
|
-
code: offense.code_name,
|
190
|
-
message: offense.message,
|
191
|
-
range: range(offense),
|
192
|
-
severity: severity(offense),
|
193
|
-
source: "theme-check",
|
194
|
-
}
|
195
|
-
diagnostic["codeDescription"] = code_description(offense) unless offense.doc.nil?
|
196
|
-
diagnostic
|
197
|
-
end
|
198
|
-
|
199
|
-
def code_description(offense)
|
200
|
-
{
|
201
|
-
href: offense.doc,
|
202
|
-
}
|
203
|
-
end
|
204
|
-
|
205
|
-
def severity(offense)
|
206
|
-
case offense.severity
|
207
|
-
when :error
|
208
|
-
1
|
209
|
-
when :suggestion
|
210
|
-
2
|
211
|
-
when :style
|
212
|
-
3
|
213
|
-
else
|
214
|
-
4
|
215
|
-
end
|
216
|
-
end
|
217
|
-
|
218
|
-
def range(offense)
|
219
|
-
{
|
220
|
-
start: {
|
221
|
-
line: offense.start_line,
|
222
|
-
character: offense.start_column,
|
223
|
-
},
|
224
|
-
end: {
|
225
|
-
line: offense.end_line,
|
226
|
-
character: offense.end_column,
|
227
|
-
},
|
228
|
-
}
|
229
|
-
end
|
230
|
-
|
231
|
-
def send_message(message)
|
232
|
-
message[:jsonrpc] = '2.0'
|
233
|
-
@server.send_response(message)
|
234
|
-
end
|
235
|
-
|
236
|
-
def send_response(id, result = nil, error = nil)
|
237
|
-
message = { id: id }
|
238
|
-
message[:result] = result if result
|
239
|
-
message[:error] = error if error
|
240
|
-
send_message(message)
|
241
|
-
end
|
242
|
-
|
243
|
-
def send_notification(method, params)
|
244
|
-
message = { method: method }
|
245
|
-
message[:params] = params
|
246
|
-
send_message(message)
|
247
|
-
end
|
248
|
-
|
249
155
|
def log(message)
|
250
|
-
@
|
156
|
+
@bridge.log(message)
|
251
157
|
end
|
252
158
|
|
253
159
|
def close!
|
@@ -0,0 +1,104 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ThemeCheck
|
4
|
+
module LanguageServer
|
5
|
+
class IOMessenger < Messenger
|
6
|
+
def initialize(
|
7
|
+
in_stream: STDIN,
|
8
|
+
out_stream: STDOUT,
|
9
|
+
err_stream: STDERR
|
10
|
+
)
|
11
|
+
validate!([in_stream, out_stream, err_stream])
|
12
|
+
|
13
|
+
@in = in_stream
|
14
|
+
@out = out_stream
|
15
|
+
@err = err_stream
|
16
|
+
|
17
|
+
# Because programming is fun,
|
18
|
+
#
|
19
|
+
# Ruby on Windows turns \n into \r\n. Which means that \r\n
|
20
|
+
# gets turned into \r\r\n. Which means that the protocol
|
21
|
+
# breaks on windows unless we turn STDOUT into binary mode.
|
22
|
+
#
|
23
|
+
# Hours wasted: 9.
|
24
|
+
@out.binmode
|
25
|
+
|
26
|
+
@out.sync = true # do not buffer
|
27
|
+
@err.sync = true # do not buffer
|
28
|
+
|
29
|
+
# Lock for writing, otherwise messages might be interspersed.
|
30
|
+
@writer = Mutex.new
|
31
|
+
end
|
32
|
+
|
33
|
+
def read_message
|
34
|
+
length = initial_line.match(/Content-Length: (\d+)/)[1].to_i
|
35
|
+
content = ''
|
36
|
+
length_to_read = 2 + length # 2 is the empty line length (\r\n)
|
37
|
+
while content.length < length_to_read
|
38
|
+
chunk = @in.read(length_to_read - content.length)
|
39
|
+
raise DoneStreaming if chunk.nil?
|
40
|
+
content += chunk
|
41
|
+
end
|
42
|
+
content.lstrip!
|
43
|
+
rescue IOError
|
44
|
+
raise DoneStreaming
|
45
|
+
end
|
46
|
+
|
47
|
+
def send_message(message_body)
|
48
|
+
@writer.synchronize do
|
49
|
+
@out.write("Content-Length: #{message_body.bytesize}\r\n")
|
50
|
+
@out.write("\r\n")
|
51
|
+
@out.write(message_body)
|
52
|
+
@out.flush
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def log(message)
|
57
|
+
@err.puts(message)
|
58
|
+
@err.flush
|
59
|
+
end
|
60
|
+
|
61
|
+
def close_input
|
62
|
+
@in.close unless @in.closed?
|
63
|
+
end
|
64
|
+
|
65
|
+
def close_output
|
66
|
+
@err.close
|
67
|
+
@out.close
|
68
|
+
end
|
69
|
+
|
70
|
+
private
|
71
|
+
|
72
|
+
def initial_line
|
73
|
+
# Scanning for lines that fit the protocol.
|
74
|
+
while true
|
75
|
+
initial_line = @in.gets
|
76
|
+
# gets returning nil means the stream was closed.
|
77
|
+
raise DoneStreaming if initial_line.nil?
|
78
|
+
|
79
|
+
if initial_line.match(/Content-Length: (\d+)/)
|
80
|
+
break
|
81
|
+
end
|
82
|
+
end
|
83
|
+
initial_line
|
84
|
+
end
|
85
|
+
|
86
|
+
def supported_io_classes
|
87
|
+
[IO, StringIO]
|
88
|
+
end
|
89
|
+
|
90
|
+
def validate!(streams = [])
|
91
|
+
streams.each do |stream|
|
92
|
+
unless supported_io_classes.find { |klass| stream.is_a?(klass) }
|
93
|
+
raise IncompatibleStream, incompatible_stream_message
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
def incompatible_stream_message
|
99
|
+
'if provided, in_stream, out_stream, and err_stream must be a kind of '\
|
100
|
+
"one of the following: #{supported_io_classes.join(', ')}"
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ThemeCheck
|
4
|
+
module LanguageServer
|
5
|
+
class Messenger
|
6
|
+
def send_message
|
7
|
+
raise NotImplementedError
|
8
|
+
end
|
9
|
+
|
10
|
+
def read_message
|
11
|
+
raise NotImplementedError
|
12
|
+
end
|
13
|
+
|
14
|
+
def log
|
15
|
+
raise NotImplementedError
|
16
|
+
end
|
17
|
+
|
18
|
+
def close_input
|
19
|
+
raise NotImplementedError
|
20
|
+
end
|
21
|
+
|
22
|
+
def close_output
|
23
|
+
raise NotImplementedError
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|