theme-check 1.5.2 → 1.7.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/theme-check.yml +12 -4
  3. data/CHANGELOG.md +37 -0
  4. data/docs/api/html_check.md +7 -7
  5. data/docs/api/liquid_check.md +10 -10
  6. data/docs/checks/convert_include_to_render.md +1 -1
  7. data/docs/checks/missing_enable_comment.md +1 -1
  8. data/lib/theme_check/analyzer.rb +46 -17
  9. data/lib/theme_check/asset_file.rb +13 -2
  10. data/lib/theme_check/check.rb +3 -3
  11. data/lib/theme_check/checks/asset_size_css.rb +15 -0
  12. data/lib/theme_check/checks/asset_size_css_stylesheet_tag.rb +18 -1
  13. data/lib/theme_check/checks/convert_include_to_render.rb +2 -1
  14. data/lib/theme_check/checks/html_parsing_error.rb +2 -2
  15. data/lib/theme_check/checks/matching_translations.rb +1 -1
  16. data/lib/theme_check/checks/missing_required_template_files.rb +21 -7
  17. data/lib/theme_check/checks/missing_template.rb +6 -6
  18. data/lib/theme_check/checks/nested_snippet.rb +2 -2
  19. data/lib/theme_check/checks/required_layout_theme_object.rb +2 -2
  20. data/lib/theme_check/checks/syntax_error.rb +5 -5
  21. data/lib/theme_check/checks/template_length.rb +2 -2
  22. data/lib/theme_check/checks/translation_key_exists.rb +3 -1
  23. data/lib/theme_check/checks/undefined_object.rb +7 -7
  24. data/lib/theme_check/checks/unused_assign.rb +4 -4
  25. data/lib/theme_check/checks/unused_snippet.rb +8 -6
  26. data/lib/theme_check/checks/valid_json.rb +1 -1
  27. data/lib/theme_check/checks.rb +4 -2
  28. data/lib/theme_check/cli.rb +7 -4
  29. data/lib/theme_check/corrector.rb +21 -12
  30. data/lib/theme_check/disabled_check.rb +3 -3
  31. data/lib/theme_check/disabled_checks.rb +9 -9
  32. data/lib/theme_check/file_system_storage.rb +7 -2
  33. data/lib/theme_check/html_node.rb +40 -32
  34. data/lib/theme_check/html_visitor.rb +24 -12
  35. data/lib/theme_check/in_memory_storage.rb +5 -1
  36. data/lib/theme_check/json_check.rb +2 -2
  37. data/lib/theme_check/json_file.rb +9 -4
  38. data/lib/theme_check/language_server/diagnostics_tracker.rb +8 -8
  39. data/lib/theme_check/language_server/handler.rb +88 -6
  40. data/lib/theme_check/language_server/messenger.rb +57 -0
  41. data/lib/theme_check/language_server/server.rb +105 -40
  42. data/lib/theme_check/language_server.rb +1 -0
  43. data/lib/theme_check/{template.rb → liquid_file.rb} +6 -20
  44. data/lib/theme_check/liquid_node.rb +291 -0
  45. data/lib/theme_check/{visitor.rb → liquid_visitor.rb} +4 -4
  46. data/lib/theme_check/locale_diff.rb +5 -5
  47. data/lib/theme_check/node.rb +12 -230
  48. data/lib/theme_check/offense.rb +41 -15
  49. data/lib/theme_check/position.rb +1 -1
  50. data/lib/theme_check/regex_helpers.rb +1 -15
  51. data/lib/theme_check/theme.rb +1 -1
  52. data/lib/theme_check/theme_file.rb +18 -1
  53. data/lib/theme_check/theme_file_rewriter.rb +57 -0
  54. data/lib/theme_check/version.rb +1 -1
  55. data/lib/theme_check.rb +11 -9
  56. data/theme-check.gemspec +2 -1
  57. metadata +23 -6
@@ -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: ['.', '{{ ', '{% '],
@@ -24,10 +29,17 @@ module ThemeCheck
24
29
  def initialize(server)
25
30
  @server = server
26
31
  @diagnostics_tracker = DiagnosticsTracker.new
32
+ @diagnostics_lock = Mutex.new
33
+ @supports_progress = false
34
+ end
35
+
36
+ def supports_progress_notifications?
37
+ @supports_progress
27
38
  end
28
39
 
29
40
  def on_initialize(id, params)
30
41
  @root_path = root_path_from_params(params)
42
+ @supports_progress = params.dig('capabilities', 'window', 'workDoneProgress')
31
43
 
32
44
  # Tell the client we don't support anything if there's no rootPath
33
45
  return send_response(id, { capabilities: {} }) if @root_path.nil?
@@ -37,6 +49,7 @@ module ThemeCheck
37
49
  # https://microsoft.github.io/language-server-protocol/specifications/specification-current/#responseMessage
38
50
  send_response(id, {
39
51
  capabilities: CAPABILITIES,
52
+ serverInfo: SERVER_INFO,
40
53
  })
41
54
  end
42
55
 
@@ -128,6 +141,8 @@ module ThemeCheck
128
141
  end
129
142
 
130
143
  def analyze_and_send_offenses(absolute_path)
144
+ return unless @diagnostics_lock.try_lock
145
+ token = send_create_work_done_progress_request
131
146
  config = config_for_path(absolute_path)
132
147
  storage = ThemeCheck::FileSystemStorage.new(
133
148
  config.root,
@@ -137,13 +152,17 @@ module ThemeCheck
137
152
  analyzer = ThemeCheck::Analyzer.new(theme, config.enabled_checks)
138
153
 
139
154
  if @diagnostics_tracker.first_run?
140
- # Analyze the full theme on first run
155
+ send_work_done_progress_begin(token, "Full theme check")
141
156
  log("Checking #{config.root}")
142
157
  offenses = nil
143
158
  time = Benchmark.measure do
144
- offenses = analyzer.analyze_theme
159
+ offenses = analyzer.analyze_theme do |path, i, total|
160
+ send_work_done_progress_report(token, "#{i}/#{total} #{path}", (i.to_f / total * 100.0).to_i)
161
+ end
145
162
  end
146
- log("Found #{offenses.size} offenses in #{format("%0.2f", time.real)}s")
163
+ end_message = "Found #{offenses.size} offenses in #{format("%0.2f", time.real)}s"
164
+ log(end_message)
165
+ send_work_done_progress_end(token, end_message)
147
166
  send_diagnostics(offenses)
148
167
  else
149
168
  # Analyze selected files
@@ -152,14 +171,20 @@ module ThemeCheck
152
171
  # Skip if not a theme file
153
172
  if file
154
173
  log("Checking #{relative_path}")
174
+ send_work_done_progress_begin(token, "Partial theme check")
155
175
  offenses = nil
156
176
  time = Benchmark.measure do
157
- offenses = analyzer.analyze_files([file])
177
+ offenses = analyzer.analyze_files([file]) do |path, i, total|
178
+ send_work_done_progress_report(token, "#{i}/#{total} #{path}", (i.to_f / total * 100.0).to_i)
179
+ end
158
180
  end
159
- log("Found #{offenses.size} new offenses in #{format("%0.2f", time.real)}s")
181
+ end_message = "Found #{offenses.size} new offenses in #{format("%0.2f", time.real)}s"
182
+ send_work_done_progress_end(token, end_message)
183
+ log(end_message)
160
184
  send_diagnostics(offenses, [absolute_path])
161
185
  end
162
186
  end
187
+ @diagnostics_lock.unlock
163
188
  end
164
189
 
165
190
  def completions(relative_path, line, col)
@@ -228,9 +253,57 @@ module ThemeCheck
228
253
  }
229
254
  end
230
255
 
256
+ def send_create_work_done_progress_request
257
+ return unless supports_progress_notifications?
258
+ token = nil
259
+ @server.request do |id|
260
+ token = id # we'll reuse the RQID as token
261
+ send_message({
262
+ id: id,
263
+ method: "window/workDoneProgress/create",
264
+ params: {
265
+ token: id,
266
+ },
267
+ })
268
+ end
269
+ token
270
+ end
271
+
272
+ def send_work_done_progress_begin(token, title)
273
+ return unless supports_progress_notifications?
274
+ send_progress(token, {
275
+ kind: 'begin',
276
+ title: title,
277
+ cancellable: false,
278
+ percentage: 0,
279
+ })
280
+ end
281
+
282
+ def send_work_done_progress_report(token, message, percentage)
283
+ return unless supports_progress_notifications?
284
+ send_progress(token, {
285
+ kind: 'report',
286
+ message: message,
287
+ cancellable: false,
288
+ percentage: percentage,
289
+ })
290
+ end
291
+
292
+ def send_work_done_progress_end(token, message)
293
+ return unless supports_progress_notifications?
294
+ send_progress(token, {
295
+ kind: 'end',
296
+ message: message,
297
+ })
298
+ end
299
+
300
+ def send_progress(token, value)
301
+ send_notification("$/progress", token: token, value: value)
302
+ end
303
+
231
304
  def send_message(message)
232
305
  message[:jsonrpc] = '2.0'
233
- @server.send_response(message)
306
+ @server.send_message(message)
234
307
  end
235
308
 
236
309
  def send_response(id, result = nil, error = nil)
@@ -240,6 +313,15 @@ module ThemeCheck
240
313
  send_message(message)
241
314
  end
242
315
 
316
+ def send_request(method, params = nil)
317
+ @server.request do |id|
318
+ message = { id: id }
319
+ message[:method] = method
320
+ message[:params] = params if params
321
+ send_message(message)
322
+ end
323
+ end
324
+
243
325
  def send_notification(method, params)
244
326
  message = { method: method }
245
327
  message[:params] = params
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ module LanguageServer
5
+ class Messenger
6
+ def initialize
7
+ @responses = {}
8
+ @mutex = Mutex.new
9
+ @id = 0
10
+ end
11
+
12
+ # Here's how you'd use this:
13
+ #
14
+ # def some_method_that_communicates_both_ways
15
+ #
16
+ # # this will block until the JSON rpc loop has an answer
17
+ # token = @server.request do |id|
18
+ # send_create_work_done_progress_request(id, ...)
19
+ # end
20
+ #
21
+ # send_create_work_done_begin_notification(token, "...")
22
+ #
23
+ # do_stuff do |file, i, total|
24
+ # send_create_work_done_progress_notification(token, "...")
25
+ # end
26
+ #
27
+ # send_create_work_done_end_notification(token, "...")
28
+ #
29
+ # end
30
+ def request(&block)
31
+ id = @mutex.synchronize { @id += 1 }
32
+ @responses[id] = SizedQueue.new(1)
33
+
34
+ # Execute the block in the parent thread with an ID
35
+ # So that we're able to relinquish control in the right
36
+ # place when we have a response.
37
+ block.call(id)
38
+
39
+ # this call is blocking until we get a response from somewhere
40
+ result = @responses[id].pop
41
+
42
+ # cleanup when done
43
+ @responses.delete(id)
44
+
45
+ # return the response
46
+ result
47
+ end
48
+
49
+ # In the JSONRPC loop, when we find the response to the
50
+ # request, we unblock the thread that made the request with the
51
+ # response.
52
+ def respond(id, value)
53
+ @responses[id] << value
54
+ end
55
+ end
56
+ end
57
+ end
@@ -16,7 +16,8 @@ module ThemeCheck
16
16
  in_stream: STDIN,
17
17
  out_stream: STDOUT,
18
18
  err_stream: STDERR,
19
- should_raise_errors: false
19
+ should_raise_errors: false,
20
+ number_of_threads: 2
20
21
  )
21
22
  validate!([in_stream, out_stream, err_stream])
22
23
 
@@ -37,33 +38,90 @@ module ThemeCheck
37
38
  @out.sync = true # do not buffer
38
39
  @err.sync = true # do not buffer
39
40
 
41
+ # The queue holds the JSON RPC messages
42
+ @queue = Queue.new
43
+
44
+ # The JSON RPC thread pushes messages onto the queue
45
+ @json_rpc_thread = nil
46
+
47
+ # The handler threads read messages from the queue
48
+ @number_of_threads = number_of_threads
49
+ @handlers = []
50
+
51
+ # The messenger permits requests to be made from the handler
52
+ # to the language client and for those messages to be resolved in place.
53
+ @messenger = Messenger.new
54
+
55
+ # The error queue holds blocks the main thread. When filled, we exit the program.
56
+ @error = SizedQueue.new(1)
57
+
40
58
  @should_raise_errors = should_raise_errors
41
59
  end
42
60
 
43
61
  def listen
44
- loop do
45
- process_request
46
-
47
- # support ctrl+c and stuff
48
- rescue SignalException, DoneStreaming
49
- cleanup
50
- return 0
51
-
52
- rescue Exception => e # rubocop:disable Lint/RescueException
53
- raise e if should_raise_errors
54
- log(e)
55
- log(e.backtrace)
56
- return 1
62
+ start_handler_threads
63
+ start_json_rpc_thread
64
+ status_code_from_error(@error.pop)
65
+ rescue SignalException
66
+ 0
67
+ ensure
68
+ cleanup
69
+ end
70
+
71
+ def start_json_rpc_thread
72
+ @json_rpc_thread = Thread.new do
73
+ loop do
74
+ message = read_json_rpc_message
75
+ if message['method'] == 'initialize'
76
+ handle_message(message)
77
+ else
78
+ @queue << message
79
+ end
80
+ rescue Exception => e # rubocop:disable Lint/RescueException
81
+ break @error << e
82
+ end
83
+ end
84
+ end
85
+
86
+ def start_handler_threads
87
+ @number_of_threads.times do
88
+ @handlers << Thread.new do
89
+ loop do
90
+ message = @queue.pop
91
+ break if @queue.closed? && @queue.empty?
92
+ handle_message(message)
93
+ rescue Exception => e # rubocop:disable Lint/RescueException
94
+ break @error << e
95
+ end
96
+ end
57
97
  end
58
98
  end
59
99
 
60
- def send_response(response)
61
- response_body = JSON.dump(response)
62
- log(JSON.pretty_generate(response)) if $DEBUG
100
+ def status_code_from_error(e)
101
+ raise e
102
+
103
+ # support ctrl+c and stuff
104
+ rescue SignalException, DoneStreaming
105
+ 0
106
+
107
+ rescue Exception => e # rubocop:disable Lint/RescueException
108
+ raise e if should_raise_errors
109
+ log(e)
110
+ log(e.backtrace)
111
+ 2
112
+ end
113
+
114
+ def request(&block)
115
+ @messenger.request(&block)
116
+ end
117
+
118
+ def send_message(message)
119
+ message_body = JSON.dump(message)
120
+ log(JSON.pretty_generate(message)) if $DEBUG
63
121
 
64
- @out.write("Content-Length: #{response_body.bytesize}\r\n")
122
+ @out.write("Content-Length: #{message_body.bytesize}\r\n")
65
123
  @out.write("\r\n")
66
- @out.write(response_body)
124
+ @out.write(message_body)
67
125
  @out.flush
68
126
  end
69
127
 
@@ -91,17 +149,23 @@ module ThemeCheck
91
149
  "one of the following: #{supported_io_classes.join(', ')}"
92
150
  end
93
151
 
94
- def process_request
95
- request_body = read_new_content
96
- request_json = JSON.parse(request_body)
97
- log(JSON.pretty_generate(request_json)) if $DEBUG
152
+ def read_json_rpc_message
153
+ message_body = read_new_content
154
+ message_json = JSON.parse(message_body)
155
+ log(JSON.pretty_generate(message_json)) if $DEBUG
156
+ message_json
157
+ end
98
158
 
99
- id = request_json['id']
100
- method_name = request_json['method']
101
- params = request_json['params']
102
- method_name = "on_#{to_snake_case(method_name)}"
159
+ def handle_message(message)
160
+ id = message['id']
161
+ method_name = message['method']
162
+ method_name &&= "on_#{to_snake_case(method_name)}"
163
+ params = message['params']
164
+ result = message['result']
103
165
 
104
- if @handler.respond_to?(method_name)
166
+ if message.key?('result')
167
+ @messenger.respond(id, result)
168
+ elsif @handler.respond_to?(method_name)
105
169
  @handler.send(method_name, id, params)
106
170
  end
107
171
  end
@@ -128,26 +192,27 @@ module ThemeCheck
128
192
  length = initial_line.match(/Content-Length: (\d+)/)[1].to_i
129
193
  content = ''
130
194
  while content.length < length + 2
131
- begin
132
- # Why + 2? Because \r\n
133
- content += @in.read(length + 2)
134
- rescue => e
135
- log(e)
136
- log(e.backtrace)
137
- # We have almost certainly been disconnected from the server
138
- cleanup
139
- raise DoneStreaming
140
- end
195
+ # Why + 2? Because \r\n
196
+ content += @in.read(length + 2)
197
+ raise DoneStreaming if @in.closed?
141
198
  end
142
199
 
143
200
  content
144
201
  end
145
202
 
146
203
  def cleanup
204
+ # Stop listenting to RPC calls
205
+ @in.close unless @in.closed?
206
+ # Wait for rpc loop to close
207
+ @json_rpc_thread&.join if @json_rpc_thread&.alive?
208
+ # Close the queue
209
+ @queue.close unless @queue.closed?
210
+ # Give 10 seconds for the handlers to wrap up what they were
211
+ # doing/emptying the queue. 👀 unit tests.
212
+ @handlers.each { |thread| thread.join(10) if thread.alive? }
213
+ ensure
147
214
  @err.close
148
215
  @out.close
149
- rescue
150
- # I did my best
151
216
  end
152
217
  end
153
218
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
  require_relative "language_server/protocol"
3
+ require_relative "language_server/messenger"
3
4
  require_relative "language_server/constants"
4
5
  require_relative "language_server/uri_helper"
5
6
  require_relative "language_server/handler"
@@ -1,12 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ThemeCheck
4
- class Template < ThemeFile
4
+ class LiquidFile < ThemeFile
5
5
  def write
6
- content = updated_content
6
+ content = rewriter.to_s
7
7
  if source != content
8
- @storage.write(@relative_path, content)
8
+ @storage.write(@relative_path, content.gsub("\n", @eol))
9
9
  @source = content
10
+ @rewriter = nil
10
11
  end
11
12
  end
12
13
 
@@ -26,19 +27,8 @@ module ThemeCheck
26
27
  name.start_with?('snippets')
27
28
  end
28
29
 
29
- def lines
30
- # Retain trailing newline character
31
- @lines ||= source.split("\n", -1)
32
- end
33
-
34
- # Not entirely obvious but lines is mutable, corrections are to be
35
- # applied on @lines.
36
- def updated_content
37
- lines.join("\n")
38
- end
39
-
40
- def excerpt(line)
41
- lines[line - 1].strip
30
+ def rewriter
31
+ @rewriter ||= ThemeFileRewriter.new(@relative_path, source)
42
32
  end
43
33
 
44
34
  def source_excerpt(line)
@@ -46,10 +36,6 @@ module ThemeCheck
46
36
  original_lines[line - 1].strip
47
37
  end
48
38
 
49
- def full_line(line)
50
- lines[line - 1]
51
- end
52
-
53
39
  def parse
54
40
  @ast ||= self.class.parse(source)
55
41
  end