theme-check 1.6.0 → 1.7.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -0
  3. data/data/shopify_liquid/tags.yml +9 -9
  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 +41 -17
  9. data/lib/theme_check/asset_file.rb +1 -1
  10. data/lib/theme_check/check.rb +2 -2
  11. data/lib/theme_check/checks/html_parsing_error.rb +2 -2
  12. data/lib/theme_check/checks/matching_translations.rb +1 -1
  13. data/lib/theme_check/checks/missing_template.rb +6 -6
  14. data/lib/theme_check/checks/nested_snippet.rb +2 -2
  15. data/lib/theme_check/checks/required_layout_theme_object.rb +2 -2
  16. data/lib/theme_check/checks/syntax_error.rb +5 -5
  17. data/lib/theme_check/checks/template_length.rb +2 -2
  18. data/lib/theme_check/checks/translation_key_exists.rb +1 -13
  19. data/lib/theme_check/checks/undefined_object.rb +7 -7
  20. data/lib/theme_check/checks/unused_assign.rb +4 -4
  21. data/lib/theme_check/checks/unused_snippet.rb +7 -7
  22. data/lib/theme_check/checks/valid_json.rb +1 -1
  23. data/lib/theme_check/checks.rb +4 -2
  24. data/lib/theme_check/cli.rb +1 -1
  25. data/lib/theme_check/corrector.rb +6 -6
  26. data/lib/theme_check/disabled_check.rb +3 -3
  27. data/lib/theme_check/disabled_checks.rb +9 -9
  28. data/lib/theme_check/exceptions.rb +1 -0
  29. data/lib/theme_check/file_system_storage.rb +4 -0
  30. data/lib/theme_check/html_node.rb +36 -28
  31. data/lib/theme_check/html_visitor.rb +6 -6
  32. data/lib/theme_check/in_memory_storage.rb +1 -1
  33. data/lib/theme_check/json_check.rb +2 -2
  34. data/lib/theme_check/language_server/bridge.rb +128 -0
  35. data/lib/theme_check/language_server/channel.rb +69 -0
  36. data/lib/theme_check/language_server/completion_providers/tag_completion_provider.rb +3 -1
  37. data/lib/theme_check/language_server/diagnostics_engine.rb +125 -0
  38. data/lib/theme_check/language_server/diagnostics_tracker.rb +8 -8
  39. data/lib/theme_check/language_server/handler.rb +20 -117
  40. data/lib/theme_check/language_server/io_messenger.rb +97 -0
  41. data/lib/theme_check/language_server/messenger.rb +27 -0
  42. data/lib/theme_check/language_server/server.rb +95 -104
  43. data/lib/theme_check/language_server.rb +6 -1
  44. data/lib/theme_check/{template.rb → liquid_file.rb} +2 -2
  45. data/lib/theme_check/liquid_node.rb +291 -0
  46. data/lib/theme_check/{visitor.rb → liquid_visitor.rb} +4 -4
  47. data/lib/theme_check/locale_diff.rb +14 -7
  48. data/lib/theme_check/node.rb +12 -225
  49. data/lib/theme_check/offense.rb +15 -15
  50. data/lib/theme_check/position.rb +1 -1
  51. data/lib/theme_check/shopify_liquid/system_translations.rb +35 -0
  52. data/lib/theme_check/shopify_liquid/tag.rb +19 -1
  53. data/lib/theme_check/shopify_liquid.rb +1 -0
  54. data/lib/theme_check/theme.rb +1 -1
  55. data/lib/theme_check/{template_rewriter.rb → theme_file_rewriter.rb} +1 -1
  56. data/lib/theme_check/version.rb +1 -1
  57. data/lib/theme_check.rb +11 -10
  58. data/theme-check.gemspec +1 -1
  59. metadata +14 -7
@@ -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,22 +26,24 @@ module ThemeCheck
21
26
  },
22
27
  }
23
28
 
24
- def initialize(server)
25
- @server = server
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
- # https://microsoft.github.io/language-server-protocol/specifications/specification-current/#responseMessage
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
 
@@ -58,7 +65,7 @@ module ThemeCheck
58
65
  def on_text_document_did_open(_id, params)
59
66
  relative_path = relative_path_from_text_document_uri(params)
60
67
  @storage.write(relative_path, text_document_text(params))
61
- analyze_and_send_offenses(text_document_uri(params)) if @diagnostics_tracker.first_run?
68
+ analyze_and_send_offenses(text_document_uri(params)) if @diagnostics_engine.first_run?
62
69
  end
63
70
 
64
71
  def on_text_document_did_save(_id, params)
@@ -67,14 +74,14 @@ module ThemeCheck
67
74
 
68
75
  def on_text_document_document_link(id, params)
69
76
  relative_path = relative_path_from_text_document_uri(params)
70
- send_response(id, document_links(relative_path))
77
+ @bridge.send_response(id, document_links(relative_path))
71
78
  end
72
79
 
73
80
  def on_text_document_completion(id, params)
74
81
  relative_path = relative_path_from_text_document_uri(params)
75
82
  line = params.dig('position', 'line')
76
83
  col = params.dig('position', 'character')
77
- send_response(id, completions(relative_path, line, col))
84
+ @bridge.send_response(id, completions(relative_path, line, col))
78
85
  end
79
86
 
80
87
  private
@@ -128,38 +135,10 @@ module ThemeCheck
128
135
  end
129
136
 
130
137
  def analyze_and_send_offenses(absolute_path)
131
- config = config_for_path(absolute_path)
132
- storage = ThemeCheck::FileSystemStorage.new(
133
- config.root,
134
- ignored_patterns: config.ignored_patterns
138
+ @diagnostics_engine.analyze_and_send_offenses(
139
+ absolute_path,
140
+ config_for_path(absolute_path)
135
141
  )
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
142
  end
164
143
 
165
144
  def completions(relative_path, line, col)
@@ -170,84 +149,8 @@ module ThemeCheck
170
149
  @document_link_engine.document_links(relative_path)
171
150
  end
172
151
 
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
152
  def log(message)
250
- @server.log(message)
153
+ @bridge.log(message)
251
154
  end
252
155
 
253
156
  def close!
@@ -0,0 +1,97 @@
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
+ end
29
+
30
+ def read_message
31
+ length = initial_line.match(/Content-Length: (\d+)/)[1].to_i
32
+ content = ''
33
+ length_to_read = 2 + length # 2 is the empty line length (\r\n)
34
+ while content.length < length_to_read
35
+ chunk = @in.read(length_to_read - content.length)
36
+ raise DoneStreaming if chunk.nil?
37
+ content += chunk
38
+ end
39
+ content.lstrip!
40
+ end
41
+
42
+ def send_message(message_body)
43
+ @out.write("Content-Length: #{message_body.bytesize}\r\n")
44
+ @out.write("\r\n")
45
+ @out.write(message_body)
46
+ @out.flush
47
+ end
48
+
49
+ def log(message)
50
+ @err.puts(message)
51
+ @err.flush
52
+ end
53
+
54
+ def close_input
55
+ @in.close unless @in.closed?
56
+ end
57
+
58
+ def close_output
59
+ @err.close
60
+ @out.close
61
+ end
62
+
63
+ private
64
+
65
+ def initial_line
66
+ # Scanning for lines that fit the protocol.
67
+ while true
68
+ initial_line = @in.gets
69
+ # gets returning nil means the stream was closed.
70
+ raise DoneStreaming if initial_line.nil?
71
+
72
+ if initial_line.match(/Content-Length: (\d+)/)
73
+ break
74
+ end
75
+ end
76
+ initial_line
77
+ end
78
+
79
+ def supported_io_classes
80
+ [IO, StringIO]
81
+ end
82
+
83
+ def validate!(streams = [])
84
+ streams.each do |stream|
85
+ unless supported_io_classes.find { |klass| stream.is_a?(klass) }
86
+ raise IncompatibleStream, incompatible_stream_message
87
+ end
88
+ end
89
+ end
90
+
91
+ def incompatible_stream_message
92
+ 'if provided, in_stream, out_stream, and err_stream must be a kind of '\
93
+ "one of the following: #{supported_io_classes.join(', ')}"
94
+ end
95
+ end
96
+ end
97
+ 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
@@ -13,141 +13,132 @@ module ThemeCheck
13
13
  attr_reader :should_raise_errors
14
14
 
15
15
  def initialize(
16
- in_stream: STDIN,
17
- out_stream: STDOUT,
18
- err_stream: STDERR,
19
- should_raise_errors: false
16
+ messenger:,
17
+ should_raise_errors: false,
18
+ number_of_threads: 2
20
19
  )
21
- validate!([in_stream, out_stream, err_stream])
20
+ # This is what does the IO
21
+ @messenger = messenger
22
22
 
23
- @handler = Handler.new(self)
24
- @in = in_stream
25
- @out = out_stream
26
- @err = err_stream
23
+ # This is what you use to communicate with the language client
24
+ @bridge = Bridge.new(@messenger)
27
25
 
28
- # Because programming is fun,
29
- #
30
- # Ruby on Windows turns \n into \r\n. Which means that \r\n
31
- # gets turned into \r\r\n. Which means that the protocol
32
- # breaks on windows unless we turn STDOUT into binary mode.
33
- #
34
- # Hours wasted: 9.
35
- @out.binmode
26
+ # The handler handles messages from the language client
27
+ @handler = Handler.new(@bridge)
36
28
 
37
- @out.sync = true # do not buffer
38
- @err.sync = true # do not buffer
29
+ # The queue holds the JSON RPC messages
30
+ @queue = Queue.new
39
31
 
40
- @should_raise_errors = should_raise_errors
41
- end
32
+ # The JSON RPC thread pushes messages onto the queue
33
+ @json_rpc_thread = nil
42
34
 
43
- 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
57
- end
58
- end
35
+ # The handler threads read messages from the queue
36
+ @number_of_threads = number_of_threads
37
+ @handlers = []
59
38
 
60
- def send_response(response)
61
- response_body = JSON.dump(response)
62
- log(JSON.pretty_generate(response)) if $DEBUG
39
+ # The error queue holds blocks the main thread. When filled, we exit the program.
40
+ @error = SizedQueue.new(1)
63
41
 
64
- @out.write("Content-Length: #{response_body.bytesize}\r\n")
65
- @out.write("\r\n")
66
- @out.write(response_body)
67
- @out.flush
42
+ @should_raise_errors = should_raise_errors
68
43
  end
69
44
 
70
- def log(message)
71
- @err.puts(message)
72
- @err.flush
45
+ def listen
46
+ start_handler_threads
47
+ start_json_rpc_thread
48
+ status_code = status_code_from_error(@error.pop)
49
+ cleanup(status_code)
50
+ rescue SignalException
51
+ 0
73
52
  end
74
53
 
75
- private
76
-
77
- def supported_io_classes
78
- [IO, StringIO]
54
+ def start_json_rpc_thread
55
+ @json_rpc_thread = Thread.new do
56
+ loop do
57
+ message = @bridge.read_message
58
+ if message['method'] == 'initialize'
59
+ handle_message(message)
60
+ elsif message.key?('result')
61
+ # Responses are handled on the main thread to prevent
62
+ # a potential deadlock caused by all handlers waiting
63
+ # for a responses.
64
+ handle_response(message)
65
+ else
66
+ @queue << message
67
+ end
68
+ rescue Exception => e # rubocop:disable Lint/RescueException
69
+ break @error << e
70
+ end
71
+ end
79
72
  end
80
73
 
81
- def validate!(streams = [])
82
- streams.each do |stream|
83
- unless supported_io_classes.find { |klass| stream.is_a?(klass) }
84
- raise IncompatibleStream, incompatible_stream_message
74
+ def start_handler_threads
75
+ @number_of_threads.times do
76
+ @handlers << Thread.new do
77
+ loop do
78
+ message = @queue.pop
79
+ break if @queue.closed? && @queue.empty?
80
+ handle_message(message)
81
+ rescue Exception => e # rubocop:disable Lint/RescueException
82
+ break @error << e
83
+ end
85
84
  end
86
85
  end
87
86
  end
88
87
 
89
- def incompatible_stream_message
90
- 'if provided, in_stream, out_stream, and err_stream must be a kind of '\
91
- "one of the following: #{supported_io_classes.join(', ')}"
88
+ def status_code_from_error(e)
89
+ raise e
90
+
91
+ # support ctrl+c and stuff
92
+ rescue SignalException, DoneStreaming
93
+ 0
94
+
95
+ rescue Exception => e # rubocop:disable Lint/RescueException
96
+ raise e if should_raise_errors
97
+ @bridge.log(e)
98
+ @bridge.log(e.backtrace)
99
+ 2
92
100
  end
93
101
 
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
102
+ private
98
103
 
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)}"
104
+ def handle_message(message)
105
+ id = message['id']
106
+ method_name = message['method']
107
+ method_name &&= "on_#{to_snake_case(method_name)}"
108
+ params = message['params']
103
109
 
104
110
  if @handler.respond_to?(method_name)
105
111
  @handler.send(method_name, id, params)
106
112
  end
107
113
  end
108
114
 
109
- def to_snake_case(method_name)
110
- StringHelpers.underscore(method_name.gsub(/[^\w]/, '_'))
115
+ def handle_response(message)
116
+ id = message['id']
117
+ result = message['result']
118
+ @bridge.receive_response(id, result)
111
119
  end
112
120
 
113
- def initial_line
114
- # Scanning for lines that fit the protocol.
115
- while true
116
- initial_line = @in.gets
117
- # gets returning nil means the stream was closed.
118
- raise DoneStreaming if initial_line.nil?
119
-
120
- if initial_line.match(/Content-Length: (\d+)/)
121
- break
122
- end
123
- end
124
- initial_line
125
- end
126
-
127
- def read_new_content
128
- length = initial_line.match(/Content-Length: (\d+)/)[1].to_i
129
- content = ''
130
- 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
141
- end
142
-
143
- content
121
+ def to_snake_case(method_name)
122
+ StringHelpers.underscore(method_name.gsub(/[^\w]/, '_'))
144
123
  end
145
124
 
146
- def cleanup
147
- @err.close
148
- @out.close
149
- rescue
150
- # I did my best
125
+ def cleanup(status_code)
126
+ # Stop listenting to RPC calls
127
+ @messenger.close_input
128
+ # Wait for rpc loop to close
129
+ @json_rpc_thread&.join if @json_rpc_thread&.alive?
130
+ # Close the queue
131
+ @queue.close unless @queue.closed?
132
+ # Give 10 seconds for the handlers to wrap up what they were
133
+ # doing/emptying the queue. 👀 unit tests.
134
+ @handlers.each { |thread| thread.join(10) if thread.alive? }
135
+
136
+ # Hijack the status_code if an error occurred while cleaning up.
137
+ # 👀 unit tests.
138
+ return status_code_from_error(@error.pop) unless @error.empty?
139
+ status_code
140
+ ensure
141
+ @messenger.close_output
151
142
  end
152
143
  end
153
144
  end
@@ -1,6 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
  require_relative "language_server/protocol"
3
3
  require_relative "language_server/constants"
4
+ require_relative "language_server/channel"
5
+ require_relative "language_server/messenger"
6
+ require_relative "language_server/io_messenger"
7
+ require_relative "language_server/bridge"
4
8
  require_relative "language_server/uri_helper"
5
9
  require_relative "language_server/handler"
6
10
  require_relative "language_server/server"
@@ -12,6 +16,7 @@ require_relative "language_server/completion_engine"
12
16
  require_relative "language_server/document_link_provider"
13
17
  require_relative "language_server/document_link_engine"
14
18
  require_relative "language_server/diagnostics_tracker"
19
+ require_relative "language_server/diagnostics_engine"
15
20
 
16
21
  Dir[__dir__ + "/language_server/completion_providers/*.rb"].each do |file|
17
22
  require file
@@ -24,7 +29,7 @@ end
24
29
  module ThemeCheck
25
30
  module LanguageServer
26
31
  def self.start
27
- Server.new.listen
32
+ Server.new(messenger: IOMessenger.new).listen
28
33
  end
29
34
  end
30
35
  end
@@ -1,7 +1,7 @@
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
6
  content = rewriter.to_s
7
7
  if source != content
@@ -28,7 +28,7 @@ module ThemeCheck
28
28
  end
29
29
 
30
30
  def rewriter
31
- @rewriter ||= TemplateRewriter.new(@relative_path, source)
31
+ @rewriter ||= ThemeFileRewriter.new(@relative_path, source)
32
32
  end
33
33
 
34
34
  def source_excerpt(line)