theme-check 1.7.2 → 1.9.2

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 (84) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/CHANGELOG.md +47 -0
  4. data/README.md +10 -0
  5. data/RELEASING.md +13 -0
  6. data/config/default.yml +5 -0
  7. data/data/shopify_liquid/deprecated_filters.yml +4 -0
  8. data/data/shopify_liquid/filters.yml +3 -1
  9. data/docs/checks/TEMPLATE.md.erb +24 -19
  10. data/docs/checks/schema_json_format.md +76 -0
  11. data/docs/language_server/code-action-command-palette.png +0 -0
  12. data/docs/language_server/code-action-flow.png +0 -0
  13. data/docs/language_server/code-action-keyboard.png +0 -0
  14. data/docs/language_server/code-action-light-bulb.png +0 -0
  15. data/docs/language_server/code-action-problem.png +0 -0
  16. data/docs/language_server/code-action-quickfix.png +0 -0
  17. data/docs/language_server/how_to_correct_code_with_code_actions_and_execute_command.md +197 -0
  18. data/exe/theme-check-language-server +0 -4
  19. data/lib/theme_check/checks/asset_size_app_block_css.rb +2 -3
  20. data/lib/theme_check/checks/asset_size_app_block_javascript.rb +2 -3
  21. data/lib/theme_check/checks/asset_url_filters.rb +2 -0
  22. data/lib/theme_check/checks/default_locale.rb +1 -1
  23. data/lib/theme_check/checks/deprecated_filter.rb +81 -4
  24. data/lib/theme_check/checks/deprecated_global_app_block_type.rb +2 -3
  25. data/lib/theme_check/checks/matching_schema_translations.rb +14 -9
  26. data/lib/theme_check/checks/matching_translations.rb +1 -0
  27. data/lib/theme_check/checks/missing_required_template_files.rb +3 -3
  28. data/lib/theme_check/checks/missing_template.rb +1 -1
  29. data/lib/theme_check/checks/pagination_size.rb +2 -3
  30. data/lib/theme_check/checks/remote_asset.rb +5 -0
  31. data/lib/theme_check/checks/required_directories.rb +1 -1
  32. data/lib/theme_check/checks/required_layout_theme_object.rb +9 -4
  33. data/lib/theme_check/checks/schema_json_format.rb +29 -0
  34. data/lib/theme_check/checks/space_inside_braces.rb +132 -87
  35. data/lib/theme_check/checks/translation_key_exists.rb +33 -13
  36. data/lib/theme_check/checks/unused_assign.rb +3 -2
  37. data/lib/theme_check/checks/unused_snippet.rb +1 -1
  38. data/lib/theme_check/checks/valid_html_translation.rb +1 -1
  39. data/lib/theme_check/checks/valid_schema.rb +2 -2
  40. data/lib/theme_check/corrector.rb +34 -23
  41. data/lib/theme_check/file_system_storage.rb +4 -3
  42. data/lib/theme_check/html_node.rb +122 -6
  43. data/lib/theme_check/html_visitor.rb +1 -32
  44. data/lib/theme_check/in_memory_storage.rb +9 -0
  45. data/lib/theme_check/json_helpers.rb +14 -0
  46. data/lib/theme_check/language_server/bridge.rb +19 -5
  47. data/lib/theme_check/language_server/client_capabilities.rb +27 -0
  48. data/lib/theme_check/language_server/code_action_engine.rb +32 -0
  49. data/lib/theme_check/language_server/code_action_provider.rb +42 -0
  50. data/lib/theme_check/language_server/code_action_providers/quickfix_code_action_provider.rb +83 -0
  51. data/lib/theme_check/language_server/code_action_providers/source_fix_all_code_action_provider.rb +40 -0
  52. data/lib/theme_check/language_server/configuration.rb +69 -0
  53. data/lib/theme_check/language_server/diagnostic.rb +124 -0
  54. data/lib/theme_check/language_server/diagnostics_engine.rb +15 -60
  55. data/lib/theme_check/language_server/diagnostics_manager.rb +136 -0
  56. data/lib/theme_check/language_server/document_change_corrector.rb +267 -0
  57. data/lib/theme_check/language_server/document_link_provider.rb +6 -6
  58. data/lib/theme_check/language_server/execute_command_engine.rb +19 -0
  59. data/lib/theme_check/language_server/execute_command_provider.rb +30 -0
  60. data/lib/theme_check/language_server/execute_command_providers/correction_execute_command_provider.rb +48 -0
  61. data/lib/theme_check/language_server/execute_command_providers/run_checks_execute_command_provider.rb +22 -0
  62. data/lib/theme_check/language_server/handler.rb +83 -29
  63. data/lib/theme_check/language_server/io_messenger.rb +11 -1
  64. data/lib/theme_check/language_server/protocol.rb +4 -0
  65. data/lib/theme_check/language_server/server.rb +29 -11
  66. data/lib/theme_check/language_server/uri_helper.rb +1 -0
  67. data/lib/theme_check/language_server/versioned_in_memory_storage.rb +69 -0
  68. data/lib/theme_check/language_server.rb +23 -5
  69. data/lib/theme_check/liquid_node.rb +255 -12
  70. data/lib/theme_check/locale_diff.rb +39 -8
  71. data/lib/theme_check/node.rb +16 -0
  72. data/lib/theme_check/offense.rb +27 -23
  73. data/lib/theme_check/position.rb +4 -4
  74. data/lib/theme_check/regex_helpers.rb +1 -1
  75. data/lib/theme_check/schema_helper.rb +70 -0
  76. data/lib/theme_check/storage.rb +4 -0
  77. data/lib/theme_check/tags.rb +0 -1
  78. data/lib/theme_check/theme.rb +1 -1
  79. data/lib/theme_check/theme_file.rb +8 -1
  80. data/lib/theme_check/theme_file_rewriter.rb +28 -6
  81. data/lib/theme_check/version.rb +1 -1
  82. data/lib/theme_check.rb +11 -2
  83. metadata +26 -3
  84. data/lib/theme_check/language_server/diagnostics_tracker.rb +0 -66
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
  module ThemeCheck
3
3
  class ValidSchema < LiquidCheck
4
- severity :suggestion
4
+ severity :error
5
5
  category :json
6
6
  doc docs_url(__FILE__)
7
7
 
8
8
  def on_schema(node)
9
- JSON.parse(node.value.nodelist.join)
9
+ JSON.parse(node.inner_markup)
10
10
  rescue JSON::ParserError => e
11
11
  add_offense(format_json_parse_error(e), node: node)
12
12
  end
@@ -2,52 +2,63 @@
2
2
 
3
3
  module ThemeCheck
4
4
  class Corrector
5
+ include JsonHelpers
6
+
5
7
  def initialize(theme_file:)
6
8
  @theme_file = theme_file
7
9
  end
8
10
 
9
- def insert_after(node, content)
10
- @theme_file.rewriter.insert_after(node, content)
11
+ def insert_after(node, content, character_range = nil)
12
+ @theme_file.rewriter.insert_after(node, content, character_range)
13
+ end
14
+
15
+ def insert_before(node, content, character_range = nil)
16
+ @theme_file.rewriter.insert_before(node, content, character_range)
11
17
  end
12
18
 
13
- def insert_before(node, content)
14
- @theme_file.rewriter.insert_before(node, content)
19
+ def remove(node)
20
+ @theme_file.rewriter.remove(node)
15
21
  end
16
22
 
17
- def replace(node, content)
18
- @theme_file.rewriter.replace(node, content)
23
+ def replace(node, content, character_range = nil)
24
+ @theme_file.rewriter.replace(node, content, character_range)
19
25
  node.markup = content
20
26
  end
21
27
 
28
+ def replace_inner_markup(node, content)
29
+ @theme_file.rewriter.replace_inner_markup(node, content)
30
+ end
31
+
32
+ def replace_inner_json(node, json, **pretty_json_opts)
33
+ replace_inner_markup(node, pretty_json(json, **pretty_json_opts))
34
+ end
35
+
22
36
  def wrap(node, insert_before, insert_after)
23
37
  @theme_file.rewriter.wrap(node, insert_before, insert_after)
24
38
  end
25
39
 
26
- def create(theme, relative_path, content)
27
- theme.storage.write(relative_path, content)
40
+ def create_file(storage, relative_path, content)
41
+ storage.write(relative_path, content)
28
42
  end
29
43
 
30
- def create_default_locale_json(theme)
31
- theme.default_locale_json = JsonFile.new("locales/#{theme.default_locale}.default.json", theme.storage)
32
- theme.default_locale_json.update_contents({})
44
+ def remove_file(storage, relative_path)
45
+ storage.remove(relative_path)
33
46
  end
34
47
 
35
- def remove(theme, relative_path)
36
- theme.storage.remove(relative_path)
48
+ def mkdir(storage, relative_path)
49
+ storage.mkdir(relative_path)
37
50
  end
38
51
 
39
- def mkdir(theme, relative_path)
40
- theme.storage.mkdir(relative_path)
52
+ def add_translation(json_file, path, value)
53
+ hash = json_file.content
54
+ SchemaHelper.set(hash, path, value)
55
+ json_file.update_contents(hash)
41
56
  end
42
57
 
43
- def add_default_translation_key(file, key, value)
44
- hash = file.content
45
- key.reduce(hash) do |pointer, token|
46
- return pointer[token] = value if token == key.last
47
- pointer[token] = {} unless pointer.key?(token)
48
- pointer[token]
49
- end
50
- file.update_contents(hash)
58
+ def remove_translation(json_file, path)
59
+ hash = json_file.content
60
+ SchemaHelper.delete(hash, path)
61
+ json_file.update_contents(hash)
51
62
  end
52
63
  end
53
64
  end
@@ -36,13 +36,14 @@ module ThemeCheck
36
36
  end
37
37
 
38
38
  def mkdir(relative_path)
39
- reset_memoizers unless file_exists?(relative_path)
40
-
41
- file(relative_path).mkpath unless file(relative_path).directory?
39
+ return if file_exists?(relative_path)
40
+ reset_memoizers
41
+ file(relative_path).mkpath
42
42
  end
43
43
 
44
44
  def files
45
45
  @file_array ||= glob("**/*")
46
+ .reject { |path| File.directory?(path) }
46
47
  .map { |path| path.relative_path_from(@root).to_s }
47
48
  end
48
49
 
@@ -5,12 +5,75 @@ module ThemeCheck
5
5
  class HtmlNode < Node
6
6
  extend Forwardable
7
7
  include RegexHelpers
8
+ include PositionHelper
8
9
  attr_reader :theme_file, :parent
9
10
 
10
- def initialize(value, theme_file, placeholder_values = [], parent = nil)
11
+ class << self
12
+ include RegexHelpers
13
+
14
+ def parse(liquid_file)
15
+ placeholder_values = []
16
+ parseable_source = +liquid_file.source.clone
17
+
18
+ # Replace all non-empty liquid tags with ≬{i}######≬ to prevent the HTML
19
+ # parser from freaking out. We transparently replace those placeholders in
20
+ # HtmlNode.
21
+ #
22
+ # We're using base36 to prevent index bleeding on 36^3 tags.
23
+ # `{{x}}` -> `≬#{i}≬` would properly be transformed for 46656 tags in a single file.
24
+ # Should be enough.
25
+ #
26
+ # The base10 alternative would have overflowed at 1000 (`{{x}}` -> `≬1000≬`) which seemed more likely.
27
+ #
28
+ # Didn't go with base64 because of the `=` character that would have messed with HTML parsing.
29
+ #
30
+ # (Note, we're also maintaining newline characters in there so
31
+ # that line numbers match the source...)
32
+ matches(parseable_source, LIQUID_TAG_OR_VARIABLE).each do |m|
33
+ value = m[0]
34
+ next unless value.size > 4 # skip empty tags/variables {%%} and {{}}
35
+ placeholder_values.push(value)
36
+ key = (placeholder_values.size - 1).to_s(36)
37
+
38
+ # Doing shenanigans so that line numbers match... Ugh.
39
+ keyed_placeholder = parseable_source[m.begin(0)...m.end(0)]
40
+
41
+ # First and last chars are ≬
42
+ keyed_placeholder[0] = "≬"
43
+ keyed_placeholder[-1] = "≬"
44
+
45
+ # Non newline characters are #
46
+ keyed_placeholder.gsub!(/[^\n≬]/, '#')
47
+
48
+ # First few # are replaced by the base10 ID of the tag
49
+ i = -1
50
+ keyed_placeholder.gsub!('#') do
51
+ i += 1
52
+ if i > key.size - 1
53
+ '#'
54
+ else
55
+ key[i]
56
+ end
57
+ end
58
+
59
+ # Replace source by placeholder
60
+ parseable_source[m.begin(0)...m.end(0)] = keyed_placeholder
61
+ end
62
+
63
+ new(
64
+ Nokogiri::HTML5.fragment(parseable_source, max_tree_depth: 400, max_attributes: 400),
65
+ liquid_file,
66
+ placeholder_values,
67
+ parseable_source
68
+ )
69
+ end
70
+ end
71
+
72
+ def initialize(value, theme_file, placeholder_values, parseable_source, parent = nil)
11
73
  @value = value
12
74
  @theme_file = theme_file
13
75
  @placeholder_values = placeholder_values
76
+ @parseable_source = parseable_source
14
77
  @parent = parent
15
78
  end
16
79
 
@@ -27,11 +90,11 @@ module ThemeCheck
27
90
  def children
28
91
  @children ||= @value
29
92
  .children
30
- .map { |child| HtmlNode.new(child, theme_file, @placeholder_values, self) }
93
+ .map { |child| HtmlNode.new(child, theme_file, @placeholder_values, @parseable_source, self) }
31
94
  end
32
95
 
33
96
  def markup
34
- @markup ||= replace_placeholders(@value.to_html)
97
+ @markup ||= replace_placeholders(parseable_markup)
35
98
  end
36
99
 
37
100
  def line_number
@@ -39,11 +102,27 @@ module ThemeCheck
39
102
  end
40
103
 
41
104
  def start_index
42
- raise NotImplementedError
105
+ position.start_index
43
106
  end
44
107
 
45
108
  def end_index
46
- raise NotImplementedError
109
+ position.end_index
110
+ end
111
+
112
+ def start_row
113
+ position.start_row
114
+ end
115
+
116
+ def start_column
117
+ position.start_column
118
+ end
119
+
120
+ def end_row
121
+ position.end_row
122
+ end
123
+
124
+ def end_column
125
+ position.end_column
47
126
  end
48
127
 
49
128
  def literal?
@@ -60,6 +139,35 @@ module ThemeCheck
60
139
  .to_h
61
140
  end
62
141
 
142
+ def parseable_markup
143
+ return @parseable_source if @value.name == "#document-fragment"
144
+
145
+ start_index = from_row_column_to_index(@parseable_source, line_number - 1, 0)
146
+ @parseable_source
147
+ .match(/<\s*#{name}[^>]*>/im, start_index)[0]
148
+ rescue NoMethodError
149
+ # Don't know what's up with the following issue. Don't think
150
+ # null check is correct approach. This should give us more info.
151
+ # https://github.com/Shopify/theme-check/issues/528
152
+ ThemeCheck.bug(<<~MSG)
153
+ Can't find a parseable tag of name #{name} inside the parseable HTML.
154
+
155
+ Tag name:
156
+ #{@value.name.inspect}
157
+
158
+ File:
159
+ #{@theme_file.relative_path}
160
+
161
+ Line number:
162
+ #{line_number}
163
+
164
+ Excerpt:
165
+ ```
166
+ #{@parseable_source.lines[line_number - 1...line_number + 5]}
167
+ ```
168
+ MSG
169
+ end
170
+
63
171
  def content
64
172
  @content ||= replace_placeholders(@value.content)
65
173
  end
@@ -74,10 +182,18 @@ module ThemeCheck
74
182
 
75
183
  private
76
184
 
185
+ def position
186
+ @position ||= Position.new(
187
+ markup,
188
+ theme_file.source,
189
+ line_number_1_indexed: line_number,
190
+ )
191
+ end
192
+
77
193
  def replace_placeholders(string)
78
194
  # Replace all ≬{i}####≬ with the actual content.
79
195
  string.gsub(HTML_LIQUID_PLACEHOLDER) do |match|
80
- key = /[0-9a-z]+/.match(match)[0]
196
+ key = /[0-9a-z]+/.match(match.gsub("\n", ''))[0]
81
197
  @placeholder_values[key.to_i(36)]
82
198
  end
83
199
  end
@@ -4,7 +4,6 @@ require "forwardable"
4
4
 
5
5
  module ThemeCheck
6
6
  class HtmlVisitor
7
- include RegexHelpers
8
7
  attr_reader :checks
9
8
 
10
9
  def initialize(checks)
@@ -12,43 +11,13 @@ module ThemeCheck
12
11
  end
13
12
 
14
13
  def visit_liquid_file(liquid_file)
15
- doc, placeholder_values = parse(liquid_file)
16
- visit(HtmlNode.new(doc, liquid_file, placeholder_values))
14
+ visit(HtmlNode.parse(liquid_file))
17
15
  rescue ArgumentError => e
18
16
  call_checks(:on_parse_error, e, liquid_file)
19
17
  end
20
18
 
21
19
  private
22
20
 
23
- def parse(liquid_file)
24
- placeholder_values = []
25
- parseable_source = +liquid_file.source.clone
26
-
27
- # Replace all non-empty liquid tags with ≬{i}######≬ to prevent the HTML
28
- # parser from freaking out. We transparently replace those placeholders in
29
- # HtmlNode.
30
- #
31
- # We're using base36 to prevent index bleeding on 36^3 tags.
32
- # `{{x}}` -> `≬#{i}≬` would properly be transformed for 46656 tags in a single file.
33
- # Should be enough.
34
- #
35
- # The base10 alternative would have overflowed at 1000 (`{{x}}` -> `≬1000≬`) which seemed more likely.
36
- #
37
- # Didn't go with base64 because of the `=` character that would have messed with HTML parsing.
38
- matches(parseable_source, LIQUID_TAG_OR_VARIABLE).each do |m|
39
- value = m[0]
40
- next unless value.size > 4 # skip empty tags/variables {%%} and {{}}
41
- placeholder_values.push(value)
42
- key = (placeholder_values.size - 1).to_s(36)
43
- parseable_source[m.begin(0)...m.end(0)] = "≬#{key.ljust(m.end(0) - m.begin(0) - 2, '#')}≬"
44
- end
45
-
46
- [
47
- Nokogiri::HTML5.fragment(parseable_source, max_tree_depth: 400, max_attributes: 400),
48
- placeholder_values,
49
- ]
50
- end
51
-
52
21
  def visit(node)
53
22
  call_checks(:on_element, node) if node.element?
54
23
  call_checks(:"on_#{node.name}", node)
@@ -6,6 +6,8 @@
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
9
+ attr_reader :root
10
+
9
11
  def initialize(files = {}, root = "/dev/null")
10
12
  @files = files
11
13
  @root = Pathname.new(root)
@@ -29,6 +31,7 @@ module ThemeCheck
29
31
 
30
32
  def mkdir(relative_path)
31
33
  @files[relative_path] = nil
34
+ reset_memoizers
32
35
  end
33
36
 
34
37
  def files
@@ -46,5 +49,11 @@ module ThemeCheck
46
49
  def relative_path(absolute_path)
47
50
  Pathname.new(absolute_path).relative_path_from(@root).to_s
48
51
  end
52
+
53
+ private
54
+
55
+ def reset_memoizers
56
+ @directories = nil
57
+ end
49
58
  end
50
59
  end
@@ -5,5 +5,19 @@ module ThemeCheck
5
5
  message = error.message[/\d+: (.+)$/, 1] || 'Invalid syntax'
6
6
  "#{message} in JSON"
7
7
  end
8
+
9
+ def pretty_json(hash, start_level: 1, indent: " ")
10
+ start_indent = indent * start_level
11
+
12
+ <<~JSON
13
+
14
+ #{start_indent}#{JSON.pretty_generate(
15
+ hash,
16
+ indent: indent,
17
+ array_nl: "\n#{start_indent}",
18
+ object_nl: "\n#{start_indent}",
19
+ )}
20
+ JSON
21
+ end
8
22
  end
9
23
  end
@@ -37,15 +37,15 @@ module ThemeCheck
37
37
 
38
38
  def read_message
39
39
  message_body = @messenger.read_message
40
- message_json = JSON.parse(message_body)
41
- @messenger.log(JSON.pretty_generate(message_json)) if $DEBUG
40
+ message_json = JSON.parse(message_body, symbolize_names: true)
41
+ @messenger.log(JSON.pretty_generate(message_json)) if ThemeCheck.debug?
42
42
  message_json
43
43
  end
44
44
 
45
45
  def send_message(message_hash)
46
46
  message_hash[:jsonrpc] = '2.0'
47
47
  message_body = JSON.dump(message_hash)
48
- @messenger.log(JSON.pretty_generate(message_hash)) if $DEBUG
48
+ @messenger.log(JSON.pretty_generate(message_hash)) if ThemeCheck.debug?
49
49
  @messenger.send_message(message_body)
50
50
  end
51
51
 
@@ -68,11 +68,25 @@ module ThemeCheck
68
68
  # https://microsoft.github.io/language-server-protocol/specifications/specification-current/#responseMessage
69
69
  def send_response(id, result = nil, error = nil)
70
70
  message = { id: id }
71
- message[:result] = result if result
72
- message[:error] = error if error
71
+ if error
72
+ message[:error] = error
73
+ else
74
+ message[:result] = result
75
+ end
73
76
  send_message(message)
74
77
  end
75
78
 
79
+ # https://microsoft.github.io/language-server-protocol/specifications/specification-3-17/#responseError
80
+ def send_internal_error(id, e)
81
+ send_response(id, nil, {
82
+ code: ErrorCodes::INTERNAL_ERROR,
83
+ message: <<~EOS,
84
+ #{e.class}: #{e.message}
85
+ #{e.backtrace.join("\n ")}
86
+ EOS
87
+ })
88
+ end
89
+
76
90
  # https://microsoft.github.io/language-server-protocol/specifications/specification-current/#notificationMessage
77
91
  def send_notification(method, params)
78
92
  message = { method: method }
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ module LanguageServer
5
+ class ClientCapabilities
6
+ def initialize(capabilities)
7
+ @capabilities = capabilities
8
+ end
9
+
10
+ def supports_work_done_progress?
11
+ @capabilities.dig(:window, :workDoneProgress) || false
12
+ end
13
+
14
+ def supports_workspace_configuration?
15
+ @capabilities.dig(:workspace, :configuration) || false
16
+ end
17
+
18
+ def supports_workspace_did_change_configuration_dynamic_registration?
19
+ @capabilities.dig(:workspace, :didChangeConfiguration, :dynamicRegistration) || false
20
+ end
21
+
22
+ def initialization_option(key)
23
+ @capabilities.dig(:initializationOptions, key)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ module LanguageServer
5
+ class CodeActionEngine
6
+ include PositionHelper
7
+
8
+ def initialize(storage, diagnostics_manager)
9
+ @storage = storage
10
+ @providers = CodeActionProvider.all.map { |c| c.new(storage, diagnostics_manager) }
11
+ end
12
+
13
+ def code_actions(absolute_path, start_position, end_position, only_kinds = [])
14
+ relative_path = @storage.relative_path(absolute_path)
15
+ buffer = @storage.read(relative_path)
16
+ start_index = from_row_column_to_index(buffer, start_position[0], start_position[1])
17
+ end_index = from_row_column_to_index(buffer, end_position[0], end_position[1])
18
+ range = (start_index...end_index)
19
+
20
+ @providers
21
+ .filter do |provider|
22
+ only_kinds.empty? ||
23
+ only_kinds.include?(provider.kind) ||
24
+ only_kinds.include?(provider.base_kind)
25
+ end
26
+ .flat_map do |provider|
27
+ provider.code_actions(relative_path, range)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ module LanguageServer
5
+ class CodeActionProvider
6
+ class << self
7
+ def all
8
+ @all ||= []
9
+ end
10
+
11
+ def inherited(subclass)
12
+ all << subclass
13
+ end
14
+
15
+ def kind(k = nil)
16
+ @kind = k unless k.nil?
17
+ @kind
18
+ end
19
+ end
20
+
21
+ attr_reader :storage
22
+ attr_reader :diagnostics_manager
23
+
24
+ def initialize(storage, diagnostics_manager)
25
+ @storage = storage
26
+ @diagnostics_manager = diagnostics_manager
27
+ end
28
+
29
+ def kind
30
+ self.class.kind
31
+ end
32
+
33
+ def base_kind
34
+ kind.split('.')[0]
35
+ end
36
+
37
+ def code_actions(relative_path, range)
38
+ raise NotImplementedError
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ module LanguageServer
5
+ class QuickfixCodeActionProvider < CodeActionProvider
6
+ kind "quickfix"
7
+
8
+ def code_actions(relative_path, range)
9
+ correctable_diagnostics = diagnostics_manager
10
+ .diagnostics(relative_path)
11
+ .filter(&:correctable?)
12
+ .reject do |diagnostic|
13
+ # We cannot quickfix if the buffer was modified. This means
14
+ # our diagnostics and InMemoryStorage are out of sync.
15
+ diagnostic.file_version != storage.version(diagnostic.relative_path)
16
+ end
17
+
18
+ diagnostics_under_cursor = correctable_diagnostics
19
+ .filter { |diagnostic| diagnostic.offense.in_range?(range) }
20
+
21
+ return [] if diagnostics_under_cursor.empty?
22
+
23
+ (
24
+ quickfix_cursor_code_actions(diagnostics_under_cursor) +
25
+ quickfix_all_of_type_code_actions(diagnostics_under_cursor, correctable_diagnostics) +
26
+ quickfix_all_code_action(correctable_diagnostics)
27
+ )
28
+ end
29
+
30
+ private
31
+
32
+ def quickfix_cursor_code_actions(diagnostics)
33
+ diagnostics.map do |diagnostic|
34
+ {
35
+ title: "Fix this #{diagnostic.code} problem: #{diagnostic.message}",
36
+ kind: kind,
37
+ diagnostics: [diagnostic.to_h],
38
+ isPreferred: true,
39
+ command: {
40
+ title: 'quickfix',
41
+ command: CorrectionExecuteCommandProvider.command,
42
+ arguments: [diagnostic.to_h],
43
+ },
44
+ }
45
+ end
46
+ end
47
+
48
+ def quickfix_all_of_type_code_actions(cursor_diagnostics, correctable_diagnostics)
49
+ codes = Set.new(cursor_diagnostics.map(&:code))
50
+ correctable_diagnostics_by_code = correctable_diagnostics.group_by(&:code)
51
+ codes.flat_map do |code|
52
+ diagnostics = correctable_diagnostics_by_code[code].map(&:to_h)
53
+ return [] unless diagnostics.size > 1
54
+ {
55
+ title: "Fix all #{code} problems",
56
+ kind: kind,
57
+ diagnostics: diagnostics,
58
+ command: {
59
+ title: 'quickfix',
60
+ command: CorrectionExecuteCommandProvider.command,
61
+ arguments: diagnostics,
62
+ },
63
+ }
64
+ end
65
+ end
66
+
67
+ def quickfix_all_code_action(diagnostics)
68
+ return [] unless diagnostics.size > 1
69
+ diagnostics = diagnostics.map(&:to_h)
70
+ [{
71
+ title: "Fix all auto-fixable problems",
72
+ kind: kind,
73
+ diagnostics: diagnostics,
74
+ command: {
75
+ title: 'quickfix',
76
+ command: CorrectionExecuteCommandProvider.command,
77
+ arguments: diagnostics,
78
+ },
79
+ }]
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ module LanguageServer
5
+ class SourceFixAllCodeActionProvider < CodeActionProvider
6
+ kind "source.fixAll"
7
+
8
+ def code_actions(relative_path, _)
9
+ diagnostics = diagnostics_manager
10
+ .diagnostics(relative_path)
11
+ .filter(&:correctable?)
12
+ .reject do |diagnostic|
13
+ # We cannot quickfix if the buffer was modified. This means
14
+ # our diagnostics and InMemoryStorage are out of sync.
15
+ diagnostic.file_version != storage.version(diagnostic.relative_path)
16
+ end
17
+ .map(&:to_h)
18
+ diagnostics_to_code_action(diagnostics)
19
+ end
20
+
21
+ private
22
+
23
+ def diagnostics_to_code_action(diagnostics)
24
+ return [] if diagnostics.empty?
25
+ [
26
+ {
27
+ title: "Fix all Theme Check auto-fixable problems",
28
+ kind: kind,
29
+ diagnostics: diagnostics,
30
+ command: {
31
+ title: 'fixAll.file',
32
+ command: LanguageServer::CorrectionExecuteCommandProvider.command,
33
+ arguments: diagnostics,
34
+ },
35
+ },
36
+ ]
37
+ end
38
+ end
39
+ end
40
+ end