theme-check 1.7.2 → 1.9.2

Sign up to get free protection for your applications and to get access to all the features.
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