theme-check 1.6.0 → 1.7.1

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.
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)