theme-check 1.6.1 → 1.7.2

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 +2 -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 +102 -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
@@ -11,8 +11,8 @@ module ThemeCheck
11
11
 
12
12
  def initialize
13
13
  @disabled_checks = Hash.new do |hash, key|
14
- template, check_name = key
15
- hash[key] = DisabledCheck.new(template, check_name)
14
+ theme_file, check_name = key
15
+ hash[key] = DisabledCheck.new(theme_file, check_name)
16
16
  end
17
17
  end
18
18
 
@@ -20,26 +20,26 @@ module ThemeCheck
20
20
  text = comment_text(node)
21
21
  if start_disabling?(text)
22
22
  checks_from_text(text).each do |check_name|
23
- disabled = @disabled_checks[[node.template, check_name]]
23
+ disabled = @disabled_checks[[node.theme_file, check_name]]
24
24
  disabled.start_index = node.start_index
25
25
  disabled.first_line = true if node.line_number == 1
26
26
  end
27
27
  elsif stop_disabling?(text)
28
28
  checks_from_text(text).each do |check_name|
29
- disabled = @disabled_checks[[node.template, check_name]]
29
+ disabled = @disabled_checks[[node.theme_file, check_name]]
30
30
  next unless disabled
31
31
  disabled.end_index = node.end_index
32
32
  end
33
33
  end
34
34
  end
35
35
 
36
- def disabled?(check, template, check_name, index)
36
+ def disabled?(check, theme_file, check_name, index)
37
37
  return true if check.ignored_patterns&.any? do |pattern|
38
- template.relative_path.fnmatch?(pattern)
38
+ theme_file.relative_path.fnmatch?(pattern)
39
39
  end
40
40
 
41
- @disabled_checks[[template, :all]]&.disabled?(index) ||
42
- @disabled_checks[[template, check_name]]&.disabled?(index)
41
+ @disabled_checks[[theme_file, :all]]&.disabled?(index) ||
42
+ @disabled_checks[[theme_file, check_name]]&.disabled?(index)
43
43
  end
44
44
 
45
45
  def checks_missing_end_index
@@ -51,7 +51,7 @@ module ThemeCheck
51
51
  def remove_disabled_offenses(checks)
52
52
  checks.disableable.each do |check|
53
53
  check.offenses.reject! do |offense|
54
- disabled?(check, offense.template, offense.code_name, offense.start_index)
54
+ disabled?(check, offense.theme_file, offense.code_name, offense.start_index)
55
55
  end
56
56
  end
57
57
  end
@@ -22,6 +22,7 @@ module ThemeCheck
22
22
  Errno::EAGAIN,
23
23
  Errno::EHOSTUNREACH,
24
24
  Errno::ENETUNREACH,
25
+ Errno::EADDRNOTAVAIL,
25
26
  ]
26
27
 
27
28
  NET_HTTP_EXCEPTIONS = [
@@ -11,6 +11,10 @@ module ThemeCheck
11
11
  @files = {}
12
12
  end
13
13
 
14
+ def relative_path(absolute_path)
15
+ Pathname.new(absolute_path).relative_path_from(@root).to_s
16
+ end
17
+
14
18
  def path(relative_path)
15
19
  @root.join(relative_path)
16
20
  end
@@ -2,18 +2,50 @@
2
2
  require "forwardable"
3
3
 
4
4
  module ThemeCheck
5
- class HtmlNode
5
+ class HtmlNode < Node
6
6
  extend Forwardable
7
7
  include RegexHelpers
8
- attr_reader :template, :parent
8
+ attr_reader :theme_file, :parent
9
9
 
10
- def initialize(value, template, placeholder_values = [], parent = nil)
10
+ def initialize(value, theme_file, placeholder_values = [], parent = nil)
11
11
  @value = value
12
- @template = template
12
+ @theme_file = theme_file
13
13
  @placeholder_values = placeholder_values
14
14
  @parent = parent
15
15
  end
16
16
 
17
+ # @value is not forwarded because we _need_ to replace the
18
+ # placeholders for the HtmlNode to make sense.
19
+ def value
20
+ if literal?
21
+ content
22
+ else
23
+ markup
24
+ end
25
+ end
26
+
27
+ def children
28
+ @children ||= @value
29
+ .children
30
+ .map { |child| HtmlNode.new(child, theme_file, @placeholder_values, self) }
31
+ end
32
+
33
+ def markup
34
+ @markup ||= replace_placeholders(@value.to_html)
35
+ end
36
+
37
+ def line_number
38
+ @value.line
39
+ end
40
+
41
+ def start_index
42
+ raise NotImplementedError
43
+ end
44
+
45
+ def end_index
46
+ raise NotImplementedError
47
+ end
48
+
17
49
  def literal?
18
50
  @value.name == "text"
19
51
  end
@@ -22,12 +54,6 @@ module ThemeCheck
22
54
  @value.element?
23
55
  end
24
56
 
25
- def children
26
- @children ||= @value
27
- .children
28
- .map { |child| HtmlNode.new(child, template, @placeholder_values, self) }
29
- end
30
-
31
57
  def attributes
32
58
  @attributes ||= @value.attributes
33
59
  .map { |k, v| [replace_placeholders(k), replace_placeholders(v.value)] }
@@ -38,16 +64,6 @@ module ThemeCheck
38
64
  @content ||= replace_placeholders(@value.content)
39
65
  end
40
66
 
41
- # @value is not forwarded because we _need_ to replace the
42
- # placeholders for the HtmlNode to make sense.
43
- def value
44
- if literal?
45
- content
46
- else
47
- markup
48
- end
49
- end
50
-
51
67
  def name
52
68
  if @value.name == "#document-fragment"
53
69
  "document"
@@ -56,14 +72,6 @@ module ThemeCheck
56
72
  end
57
73
  end
58
74
 
59
- def markup
60
- @markup ||= replace_placeholders(@value.to_html)
61
- end
62
-
63
- def line_number
64
- @value.line
65
- end
66
-
67
75
  private
68
76
 
69
77
  def replace_placeholders(string)
@@ -11,18 +11,18 @@ module ThemeCheck
11
11
  @checks = checks
12
12
  end
13
13
 
14
- def visit_template(template)
15
- doc, placeholder_values = parse(template)
16
- visit(HtmlNode.new(doc, template, placeholder_values))
14
+ def visit_liquid_file(liquid_file)
15
+ doc, placeholder_values = parse(liquid_file)
16
+ visit(HtmlNode.new(doc, liquid_file, placeholder_values))
17
17
  rescue ArgumentError => e
18
- call_checks(:on_parse_error, e, template)
18
+ call_checks(:on_parse_error, e, liquid_file)
19
19
  end
20
20
 
21
21
  private
22
22
 
23
- def parse(template)
23
+ def parse(liquid_file)
24
24
  placeholder_values = []
25
- parseable_source = +template.source.clone
25
+ parseable_source = +liquid_file.source.clone
26
26
 
27
27
  # Replace all non-empty liquid tags with ≬{i}######≬ to prevent the HTML
28
28
  # parser from freaking out. We transparently replace those placeholders in
@@ -2,7 +2,7 @@
2
2
 
3
3
  # An in-memory storage is not written to disk. The reasons why you'd
4
4
  # want to do that are your own. The idea is to not write to disk
5
- # something that doesn't need to be there. If you have your template
5
+ # something that doesn't need to be there. If you have your theme
6
6
  # as a big hash already, leave it like that and save yourself some IO.
7
7
  module ThemeCheck
8
8
  class InMemoryStorage < Storage
@@ -4,8 +4,8 @@ module ThemeCheck
4
4
  class JsonCheck < Check
5
5
  extend ChecksTracking
6
6
 
7
- def add_offense(message, markup: nil, line_number: nil, template: nil, &block)
8
- offenses << Offense.new(check: self, message: message, markup: markup, line_number: line_number, template: template, correction: block)
7
+ def add_offense(message, markup: nil, line_number: nil, theme_file: nil, &block)
8
+ offenses << Offense.new(check: self, message: message, markup: markup, line_number: line_number, theme_file: theme_file, correction: block)
9
9
  end
10
10
  end
11
11
  end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This class exists as a bridge (or boundary) between our handlers and the outside world.
4
+ #
5
+ # It is concerned with all the Language Server Protocol constructs. i.e.
6
+ #
7
+ # - sending Hash messages as JSON
8
+ # - reading JSON messages as Hashes
9
+ # - preparing, sending and resolving requests
10
+ # - preparing and sending responses
11
+ # - preparing and sending notifications
12
+ # - preparing and sending progress notifications
13
+ #
14
+ # But it _not_ concerned by _how_ those messages are sent to the
15
+ # outside world. That's the job of the messenger.
16
+ #
17
+ # This enables us to have all the language server protocol logic
18
+ # in here living independently of how we communicate with the
19
+ # client (STDIO or websocket)
20
+ module ThemeCheck
21
+ module LanguageServer
22
+ class Bridge
23
+ attr_writer :supports_work_done_progress
24
+
25
+ def initialize(messenger)
26
+ # The messenger is responsible for IO.
27
+ # Could be STDIO or WebSockets or Mock.
28
+ @messenger = messenger
29
+
30
+ # Whether the client supports work done progress notifications
31
+ @supports_work_done_progress = false
32
+ end
33
+
34
+ def log(message)
35
+ @messenger.log(message)
36
+ end
37
+
38
+ def read_message
39
+ message_body = @messenger.read_message
40
+ message_json = JSON.parse(message_body)
41
+ @messenger.log(JSON.pretty_generate(message_json)) if $DEBUG
42
+ message_json
43
+ end
44
+
45
+ def send_message(message_hash)
46
+ message_hash[:jsonrpc] = '2.0'
47
+ message_body = JSON.dump(message_hash)
48
+ @messenger.log(JSON.pretty_generate(message_hash)) if $DEBUG
49
+ @messenger.send_message(message_body)
50
+ end
51
+
52
+ # https://microsoft.github.io/language-server-protocol/specifications/specification-current/#requestMessage
53
+ def send_request(method, params = nil)
54
+ channel = Channel.create
55
+ message = { id: channel.id }
56
+ message[:method] = method
57
+ message[:params] = params if params
58
+ send_message(message)
59
+ channel.pop
60
+ ensure
61
+ channel.close
62
+ end
63
+
64
+ def receive_response(id, result)
65
+ Channel.by_id(id) << result
66
+ end
67
+
68
+ # https://microsoft.github.io/language-server-protocol/specifications/specification-current/#responseMessage
69
+ def send_response(id, result = nil, error = nil)
70
+ message = { id: id }
71
+ message[:result] = result if result
72
+ message[:error] = error if error
73
+ send_message(message)
74
+ end
75
+
76
+ # https://microsoft.github.io/language-server-protocol/specifications/specification-current/#notificationMessage
77
+ def send_notification(method, params)
78
+ message = { method: method }
79
+ message[:params] = params
80
+ send_message(message)
81
+ end
82
+
83
+ # https://microsoft.github.io/language-server-protocol/specifications/specification-current/#progress
84
+ def send_progress(token, value)
85
+ send_notification("$/progress", token: token, value: value)
86
+ end
87
+
88
+ def supports_work_done_progress?
89
+ @supports_work_done_progress
90
+ end
91
+
92
+ def send_create_work_done_progress_request(token)
93
+ return unless supports_work_done_progress?
94
+ send_request("window/workDoneProgress/create", {
95
+ token: token,
96
+ })
97
+ end
98
+
99
+ def send_work_done_progress_begin(token, title)
100
+ return unless supports_work_done_progress?
101
+ send_progress(token, {
102
+ kind: 'begin',
103
+ title: title,
104
+ cancellable: false,
105
+ percentage: 0,
106
+ })
107
+ end
108
+
109
+ def send_work_done_progress_report(token, message, percentage)
110
+ return unless supports_work_done_progress?
111
+ send_progress(token, {
112
+ kind: 'report',
113
+ message: message,
114
+ cancellable: false,
115
+ percentage: percentage,
116
+ })
117
+ end
118
+
119
+ def send_work_done_progress_end(token, message)
120
+ return unless supports_work_done_progress?
121
+ send_progress(token, {
122
+ kind: 'end',
123
+ message: message,
124
+ })
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ module LanguageServer
5
+ # How you'd use this class:
6
+ #
7
+ # In thread #1:
8
+ # def foo
9
+ # chan = Channel.create
10
+ # send_request(chan.id, ...)
11
+ # result = chan.pop
12
+ # do_stuff_with_result(result)
13
+ # ensure
14
+ # chan.close
15
+ # end
16
+ #
17
+ # In thread #2:
18
+ # Channel.by_id(id) << result
19
+ class Channel
20
+ MUTEX = Mutex.new
21
+ CHANNELS = {}
22
+
23
+ class << self
24
+ def create
25
+ id = new_id
26
+ CHANNELS[id] = new(id)
27
+ CHANNELS[id]
28
+ end
29
+
30
+ def by_id(id)
31
+ CHANNELS[id]
32
+ end
33
+
34
+ def close(id)
35
+ CHANNELS.delete(id)
36
+ end
37
+
38
+ private
39
+
40
+ def new_id
41
+ MUTEX.synchronize do
42
+ @id ||= 0
43
+ @id += 1
44
+ end
45
+ end
46
+ end
47
+
48
+ attr_reader :id
49
+
50
+ def initialize(id)
51
+ @id = id
52
+ @response = SizedQueue.new(1)
53
+ end
54
+
55
+ def pop
56
+ @response.pop
57
+ end
58
+
59
+ def <<(value)
60
+ @response << value
61
+ end
62
+
63
+ def close
64
+ @response.close
65
+ Channel.close(id)
66
+ end
67
+ end
68
+ end
69
+ end
@@ -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
@@ -19,22 +19,22 @@ module ThemeCheck
19
19
  new_single_file_offenses = {}
20
20
  analyzed_files = analyzed_files.map { |path| Pathname.new(path) } if analyzed_files
21
21
 
22
- offenses.group_by(&:template).each do |template, template_offenses|
23
- next unless template
22
+ offenses.group_by(&:theme_file).each do |theme_file, template_offenses|
23
+ next unless theme_file
24
24
  reported_offenses = template_offenses
25
- previous_offenses = @single_files_offenses[template.path]
26
- if analyzed_files.nil? || analyzed_files.include?(template.path)
25
+ previous_offenses = @single_files_offenses[theme_file.path]
26
+ if analyzed_files.nil? || analyzed_files.include?(theme_file.path)
27
27
  # We re-analyzed the file, so we know the template_offenses are update to date.
28
28
  reported_single_file_offenses = reported_offenses.select(&:single_file?)
29
29
  if reported_single_file_offenses.any?
30
- new_single_file_offenses[template.path] = reported_single_file_offenses
30
+ new_single_file_offenses[theme_file.path] = reported_single_file_offenses
31
31
  end
32
32
  elsif previous_offenses
33
33
  # Merge in the previous ones, if some
34
34
  reported_offenses |= previous_offenses
35
35
  end
36
- yield template.path, reported_offenses
37
- reported_files << template.path
36
+ yield theme_file.path, reported_offenses
37
+ reported_files << theme_file.path
38
38
  end
39
39
 
40
40
  @single_files_offenses.each do |path, _|
@@ -51,7 +51,7 @@ module ThemeCheck
51
51
  reported_files << path
52
52
  end
53
53
 
54
- # Publish diagnostics with empty array if all issues on a previously reported template
54
+ # Publish diagnostics with empty array if all issues on a previously reported theme_file
55
55
  # have been fixed.
56
56
  (@previously_reported_files - reported_files).each do |path|
57
57
  yield path, []