theme-check 1.6.2 → 1.8.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/data/shopify_liquid/filters.yml +1 -0
  4. data/data/shopify_liquid/tags.yml +9 -9
  5. data/docs/checks/TEMPLATE.md.erb +24 -19
  6. data/exe/theme-check-language-server +0 -4
  7. data/lib/theme_check/analyzer.rb +29 -5
  8. data/lib/theme_check/checks/matching_schema_translations.rb +12 -5
  9. data/lib/theme_check/checks/required_layout_theme_object.rb +9 -4
  10. data/lib/theme_check/checks/translation_key_exists.rb +1 -13
  11. data/lib/theme_check/checks/unused_assign.rb +3 -2
  12. data/lib/theme_check/checks/unused_snippet.rb +1 -1
  13. data/lib/theme_check/corrector.rb +40 -3
  14. data/lib/theme_check/exceptions.rb +1 -0
  15. data/lib/theme_check/file_system_storage.rb +4 -0
  16. data/lib/theme_check/language_server/bridge.rb +142 -0
  17. data/lib/theme_check/language_server/channel.rb +69 -0
  18. data/lib/theme_check/language_server/completion_providers/tag_completion_provider.rb +3 -1
  19. data/lib/theme_check/language_server/diagnostics_engine.rb +125 -0
  20. data/lib/theme_check/language_server/handler.rb +24 -118
  21. data/lib/theme_check/language_server/io_messenger.rb +104 -0
  22. data/lib/theme_check/language_server/messenger.rb +27 -0
  23. data/lib/theme_check/language_server/protocol.rb +4 -0
  24. data/lib/theme_check/language_server/server.rb +111 -103
  25. data/lib/theme_check/language_server.rb +6 -1
  26. data/lib/theme_check/liquid_node.rb +33 -0
  27. data/lib/theme_check/locale_diff.rb +36 -10
  28. data/lib/theme_check/position.rb +4 -4
  29. data/lib/theme_check/shopify_liquid/system_translations.rb +35 -0
  30. data/lib/theme_check/shopify_liquid/tag.rb +19 -1
  31. data/lib/theme_check/shopify_liquid.rb +1 -0
  32. data/lib/theme_check/tags.rb +0 -1
  33. data/lib/theme_check/theme_file_rewriter.rb +13 -0
  34. data/lib/theme_check/version.rb +1 -1
  35. data/lib/theme_check.rb +4 -0
  36. 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(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
 
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 @diagnostics_tracker.first_run?
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
- config = config_for_path(absolute_path)
132
- storage = ThemeCheck::FileSystemStorage.new(
133
- config.root,
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
- @server.log(message)
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
@@ -37,5 +37,9 @@ module ThemeCheck
37
37
  FULL = 1
38
38
  INCREMENTAL = 2
39
39
  end
40
+
41
+ module ErrorCodes
42
+ INTERNAL_ERROR = -32603
43
+ end
40
44
  end
41
45
  end