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