theme-check 1.7.0 → 1.9.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (92) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/CHANGELOG.md +49 -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/data/shopify_liquid/tags.yml +9 -9
  10. data/docs/checks/TEMPLATE.md.erb +24 -19
  11. data/docs/checks/schema_json_format.md +76 -0
  12. data/docs/language_server/code-action-command-palette.png +0 -0
  13. data/docs/language_server/code-action-flow.png +0 -0
  14. data/docs/language_server/code-action-keyboard.png +0 -0
  15. data/docs/language_server/code-action-light-bulb.png +0 -0
  16. data/docs/language_server/code-action-problem.png +0 -0
  17. data/docs/language_server/code-action-quickfix.png +0 -0
  18. data/docs/language_server/how_to_correct_code_with_code_actions_and_execute_command.md +197 -0
  19. data/exe/theme-check-language-server +0 -4
  20. data/lib/theme_check/checks/asset_size_app_block_css.rb +2 -3
  21. data/lib/theme_check/checks/asset_size_app_block_javascript.rb +2 -3
  22. data/lib/theme_check/checks/asset_url_filters.rb +2 -0
  23. data/lib/theme_check/checks/default_locale.rb +1 -1
  24. data/lib/theme_check/checks/deprecated_filter.rb +79 -4
  25. data/lib/theme_check/checks/deprecated_global_app_block_type.rb +2 -3
  26. data/lib/theme_check/checks/matching_schema_translations.rb +14 -9
  27. data/lib/theme_check/checks/matching_translations.rb +1 -0
  28. data/lib/theme_check/checks/missing_required_template_files.rb +3 -3
  29. data/lib/theme_check/checks/missing_template.rb +1 -1
  30. data/lib/theme_check/checks/pagination_size.rb +2 -3
  31. data/lib/theme_check/checks/remote_asset.rb +5 -0
  32. data/lib/theme_check/checks/required_directories.rb +1 -1
  33. data/lib/theme_check/checks/required_layout_theme_object.rb +9 -4
  34. data/lib/theme_check/checks/schema_json_format.rb +29 -0
  35. data/lib/theme_check/checks/space_inside_braces.rb +132 -87
  36. data/lib/theme_check/checks/translation_key_exists.rb +33 -25
  37. data/lib/theme_check/checks/unused_assign.rb +3 -2
  38. data/lib/theme_check/checks/unused_snippet.rb +1 -1
  39. data/lib/theme_check/checks/valid_html_translation.rb +1 -1
  40. data/lib/theme_check/checks/valid_schema.rb +2 -2
  41. data/lib/theme_check/corrector.rb +34 -23
  42. data/lib/theme_check/exceptions.rb +1 -0
  43. data/lib/theme_check/file_system_storage.rb +8 -3
  44. data/lib/theme_check/html_node.rb +99 -6
  45. data/lib/theme_check/html_visitor.rb +1 -32
  46. data/lib/theme_check/in_memory_storage.rb +9 -0
  47. data/lib/theme_check/json_helpers.rb +14 -0
  48. data/lib/theme_check/language_server/bridge.rb +142 -0
  49. data/lib/theme_check/language_server/channel.rb +69 -0
  50. data/lib/theme_check/language_server/client_capabilities.rb +27 -0
  51. data/lib/theme_check/language_server/code_action_engine.rb +32 -0
  52. data/lib/theme_check/language_server/code_action_provider.rb +42 -0
  53. data/lib/theme_check/language_server/code_action_providers/quickfix_code_action_provider.rb +83 -0
  54. data/lib/theme_check/language_server/code_action_providers/source_fix_all_code_action_provider.rb +40 -0
  55. data/lib/theme_check/language_server/completion_providers/tag_completion_provider.rb +3 -1
  56. data/lib/theme_check/language_server/configuration.rb +69 -0
  57. data/lib/theme_check/language_server/diagnostic.rb +124 -0
  58. data/lib/theme_check/language_server/diagnostics_engine.rb +80 -0
  59. data/lib/theme_check/language_server/diagnostics_manager.rb +136 -0
  60. data/lib/theme_check/language_server/document_change_corrector.rb +267 -0
  61. data/lib/theme_check/language_server/document_link_provider.rb +6 -6
  62. data/lib/theme_check/language_server/execute_command_engine.rb +19 -0
  63. data/lib/theme_check/language_server/execute_command_provider.rb +30 -0
  64. data/lib/theme_check/language_server/execute_command_providers/correction_execute_command_provider.rb +48 -0
  65. data/lib/theme_check/language_server/execute_command_providers/run_checks_execute_command_provider.rb +22 -0
  66. data/lib/theme_check/language_server/handler.rb +92 -217
  67. data/lib/theme_check/language_server/io_messenger.rb +112 -0
  68. data/lib/theme_check/language_server/messenger.rb +12 -42
  69. data/lib/theme_check/language_server/protocol.rb +4 -0
  70. data/lib/theme_check/language_server/server.rb +54 -110
  71. data/lib/theme_check/language_server/uri_helper.rb +1 -0
  72. data/lib/theme_check/language_server/versioned_in_memory_storage.rb +69 -0
  73. data/lib/theme_check/language_server.rb +28 -6
  74. data/lib/theme_check/liquid_node.rb +255 -12
  75. data/lib/theme_check/locale_diff.rb +48 -10
  76. data/lib/theme_check/node.rb +16 -0
  77. data/lib/theme_check/offense.rb +27 -23
  78. data/lib/theme_check/position.rb +4 -4
  79. data/lib/theme_check/regex_helpers.rb +1 -1
  80. data/lib/theme_check/schema_helper.rb +70 -0
  81. data/lib/theme_check/shopify_liquid/system_translations.rb +35 -0
  82. data/lib/theme_check/shopify_liquid/tag.rb +19 -1
  83. data/lib/theme_check/shopify_liquid.rb +1 -0
  84. data/lib/theme_check/storage.rb +4 -0
  85. data/lib/theme_check/tags.rb +0 -1
  86. data/lib/theme_check/theme.rb +1 -1
  87. data/lib/theme_check/theme_file.rb +8 -1
  88. data/lib/theme_check/theme_file_rewriter.rb +28 -6
  89. data/lib/theme_check/version.rb +1 -1
  90. data/lib/theme_check.rb +11 -2
  91. metadata +31 -3
  92. data/lib/theme_check/language_server/diagnostics_tracker.rb +0 -66
@@ -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
@@ -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,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ module LanguageServer
5
+ class Configuration
6
+ CHECK_ON_OPEN = :"themeCheck.checkOnOpen"
7
+ CHECK_ON_SAVE = :"themeCheck.checkOnSave"
8
+ CHECK_ON_CHANGE = :"themeCheck.checkOnChange"
9
+
10
+ def initialize(bridge, capabilities)
11
+ @bridge = bridge
12
+ @capabilities = capabilities
13
+ @mutex = Mutex.new
14
+ @initialized = false
15
+ @config = {
16
+ CHECK_ON_OPEN => @capabilities.initialization_option(CHECK_ON_OPEN) || true,
17
+ CHECK_ON_SAVE => @capabilities.initialization_option(CHECK_ON_SAVE) || true,
18
+ CHECK_ON_CHANGE => @capabilities.initialization_option(CHECK_ON_CHANGE) || true,
19
+ }
20
+ end
21
+
22
+ def fetch(force: nil)
23
+ @mutex.synchronize do
24
+ return unless @capabilities.supports_workspace_configuration?
25
+ return if initialized? && !force
26
+ check_on_open, check_on_save, check_on_change = @bridge.send_request(
27
+ "workspace/configuration",
28
+ items: [
29
+ { section: CHECK_ON_OPEN },
30
+ { section: CHECK_ON_SAVE },
31
+ { section: CHECK_ON_CHANGE },
32
+ ],
33
+ )
34
+ @config[CHECK_ON_OPEN] = check_on_open unless check_on_open.nil?
35
+ @config[CHECK_ON_CHANGE] = check_on_change unless check_on_change.nil?
36
+ @config[CHECK_ON_SAVE] = check_on_save unless check_on_save.nil?
37
+ @initialized = true
38
+ end
39
+ end
40
+
41
+ def register_did_change_capability
42
+ return unless @capabilities.supports_workspace_did_change_configuration_dynamic_registration?
43
+ @bridge.send_request('client/registerCapability', registrations: [{
44
+ id: "workspace/didChangeConfiguration",
45
+ method: "workspace/didChangeConfiguration",
46
+ }])
47
+ end
48
+
49
+ def initialized?
50
+ @initialized
51
+ end
52
+
53
+ def check_on_open?
54
+ fetch # making sure we have an initialized value
55
+ @config[CHECK_ON_OPEN]
56
+ end
57
+
58
+ def check_on_save?
59
+ fetch # making sure we have for an initialized value
60
+ @config[CHECK_ON_SAVE]
61
+ end
62
+
63
+ def check_on_change?
64
+ fetch # making sure we have for an initialized value
65
+ @config[CHECK_ON_CHANGE]
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ module LanguageServer
5
+ class Diagnostic
6
+ include URIHelper
7
+
8
+ attr_reader :offense
9
+
10
+ def initialize(offense)
11
+ @offense = offense
12
+ @diagnostic = nil
13
+ end
14
+
15
+ def ==(other)
16
+ case other
17
+ when Hash, Diagnostic
18
+ to_h == other.to_h
19
+ else
20
+ raise ArgumentError
21
+ end
22
+ end
23
+
24
+ def to_h
25
+ return @diagnostic unless @diagnostic.nil?
26
+ @diagnostic = {
27
+ source: "theme-check",
28
+ code: code,
29
+ message: message,
30
+ range: range,
31
+ severity: severity,
32
+ data: data,
33
+ }
34
+ @diagnostic[:codeDescription] = code_description unless offense.doc.nil?
35
+ @diagnostic
36
+ end
37
+
38
+ def to_s
39
+ to_h.to_s
40
+ end
41
+
42
+ def single_file?
43
+ offense.single_file?
44
+ end
45
+
46
+ def correctable?
47
+ offense.correctable?
48
+ end
49
+
50
+ def code
51
+ offense.code_name
52
+ end
53
+
54
+ def message
55
+ offense.message
56
+ end
57
+
58
+ def code_description
59
+ {
60
+ href: offense.doc,
61
+ }
62
+ end
63
+
64
+ def severity
65
+ case offense.severity
66
+ when :error
67
+ 1
68
+ when :suggestion
69
+ 2
70
+ when :style
71
+ 3
72
+ else
73
+ 4
74
+ end
75
+ end
76
+
77
+ def range
78
+ {
79
+ start: {
80
+ line: offense.start_row,
81
+ character: offense.start_column,
82
+ },
83
+ end: {
84
+ line: offense.end_row,
85
+ character: offense.end_column,
86
+ },
87
+ }
88
+ end
89
+
90
+ def start_index
91
+ offense.start_index
92
+ end
93
+
94
+ def end_index
95
+ offense.end_index
96
+ end
97
+
98
+ def absolute_path
99
+ @absolute_path ||= offense&.theme_file&.path
100
+ end
101
+
102
+ def relative_path
103
+ @relative_path ||= offense&.theme_file&.relative_path
104
+ end
105
+
106
+ def uri
107
+ @uri ||= absolute_path && file_uri(absolute_path)
108
+ end
109
+
110
+ def file_version
111
+ @version ||= offense&.version
112
+ end
113
+
114
+ def data
115
+ {
116
+ absolute_path: absolute_path.to_s,
117
+ relative_path: relative_path.to_s,
118
+ uri: uri,
119
+ version: file_version,
120
+ }
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ module LanguageServer
5
+ class DiagnosticsEngine
6
+ include URIHelper
7
+
8
+ attr_reader :storage
9
+
10
+ def initialize(storage, bridge, diagnostics_manager = DiagnosticsManager.new)
11
+ @diagnostics_lock = Mutex.new
12
+ @diagnostics_manager = diagnostics_manager
13
+ @storage = storage
14
+ @bridge = bridge
15
+ @token = 0
16
+ end
17
+
18
+ def first_run?
19
+ @diagnostics_manager.first_run?
20
+ end
21
+
22
+ def analyze_and_send_offenses(absolute_path, config, force: false)
23
+ return unless @diagnostics_lock.try_lock
24
+ @token += 1
25
+ @bridge.send_create_work_done_progress_request(@token)
26
+ theme = ThemeCheck::Theme.new(storage)
27
+ analyzer = ThemeCheck::Analyzer.new(theme, config.enabled_checks)
28
+
29
+ if @diagnostics_manager.first_run? || force
30
+ @bridge.send_work_done_progress_begin(@token, "Full theme check")
31
+ @bridge.log("Checking #{storage.root}")
32
+ offenses = nil
33
+ time = Benchmark.measure do
34
+ offenses = analyzer.analyze_theme do |path, i, total|
35
+ @bridge.send_work_done_progress_report(@token, "#{i}/#{total} #{path}", (i.to_f / total * 100.0).to_i)
36
+ end
37
+ end
38
+ end_message = "Found #{offenses.size} offenses in #{format("%0.2f", time.real)}s"
39
+ @bridge.send_work_done_progress_end(@token, end_message)
40
+ send_diagnostics(offenses)
41
+ else
42
+ # Analyze selected files
43
+ relative_path = Pathname.new(storage.relative_path(absolute_path))
44
+ file = theme[relative_path]
45
+ # Skip if not a theme file
46
+ if file
47
+ @bridge.send_work_done_progress_begin(@token, "Partial theme check")
48
+ offenses = nil
49
+ time = Benchmark.measure do
50
+ offenses = analyzer.analyze_files([file]) do |path, i, total|
51
+ @bridge.send_work_done_progress_report(@token, "#{i}/#{total} #{path}", (i.to_f / total * 100.0).to_i)
52
+ end
53
+ end
54
+ end_message = "Found #{offenses.size} new offenses in #{format("%0.2f", time.real)}s"
55
+ @bridge.send_work_done_progress_end(@token, end_message)
56
+ @bridge.log(end_message)
57
+ send_diagnostics(offenses, [relative_path])
58
+ end
59
+ end
60
+ @diagnostics_lock.unlock
61
+ end
62
+
63
+ private
64
+
65
+ def send_diagnostics(offenses, analyzed_files = nil)
66
+ @diagnostics_manager.build_diagnostics(offenses, analyzed_files: analyzed_files).each do |relative_path, diagnostics|
67
+ send_diagnostic(relative_path, diagnostics)
68
+ end
69
+ end
70
+
71
+ def send_diagnostic(relative_path, diagnostics)
72
+ # https://microsoft.github.io/language-server-protocol/specifications/specification-current/#notificationMessage
73
+ @bridge.send_notification('textDocument/publishDiagnostics', {
74
+ uri: file_uri(storage.path(relative_path)),
75
+ diagnostics: diagnostics.map(&:to_h),
76
+ })
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+ require "logger"
3
+
4
+ module ThemeCheck
5
+ module LanguageServer
6
+ class DiagnosticsManager
7
+ # This class exists to facilitate LanguageServer diagnostics tracking.
8
+ #
9
+ # Motivations:
10
+ # 1. The first time we lint, we want all the errors from all the files.
11
+ # 2. If we fix all the errors in a file, we have to send an empty array for that file.
12
+ # 3. If we do a partial check, we should consider the whole theme diagnostics as valid, and return cached results
13
+ # 4. We should be able to create WorkspaceEdits from diagnostics, so that the ExecuteCommandEngine can do its job
14
+ # 5. We should clean up diagnostics that were applied by the client
15
+ def initialize
16
+ @latest_diagnostics = {} # { [Pathname(relative_path)] => Diagnostic[] }
17
+ @mutex = Mutex.new
18
+ @first_run = true
19
+ end
20
+
21
+ def first_run?
22
+ @first_run
23
+ end
24
+
25
+ def diagnostics(relative_path)
26
+ relative_path = Pathname.new(relative_path) if relative_path.is_a?(String)
27
+ @mutex.synchronize { @latest_diagnostics[relative_path] || [] }
28
+ end
29
+
30
+ def build_diagnostics(offenses, analyzed_files: nil)
31
+ @mutex.synchronize do
32
+ full_check = analyzed_files.nil?
33
+ analyzed_paths = analyzed_files.map { |path| Pathname.new(path) } unless full_check
34
+
35
+ # When analyzed_files is nil, contains all offenses.
36
+ # When analyzed_files is !nil, contains all whole theme offenses and single file offenses of the analyzed_files.
37
+ current_diagnostics = offenses
38
+ .select(&:theme_file)
39
+ .group_by(&:theme_file)
40
+ .transform_keys { |theme_file| Pathname.new(theme_file.relative_path) }
41
+ .transform_values do |theme_file_offenses|
42
+ theme_file_offenses.map { |o| Diagnostic.new(o) }
43
+ end
44
+
45
+ previous_paths = paths(@latest_diagnostics)
46
+ current_paths = paths(current_diagnostics)
47
+
48
+ diagnostics_update = (current_paths + previous_paths).map do |path|
49
+ # When doing a full_check, we either send the current
50
+ # diagnostics or an empty array to clear the diagnostics
51
+ # for that file.
52
+ if full_check
53
+ [path, current_diagnostics[path] || []]
54
+
55
+ # When doing a partial check, the single file diagnostics
56
+ # from the previous runs should be sent. Otherwise the
57
+ # latest results are the good ones.
58
+ else
59
+ new_diagnostics = current_diagnostics[path] || []
60
+ should_use_cached_results = !analyzed_paths.include?(path)
61
+ old_diagnostics = should_use_cached_results ? single_file_diagnostics(path) : []
62
+ [path, new_diagnostics + old_diagnostics]
63
+ end
64
+ end.to_h
65
+
66
+ @latest_diagnostics = diagnostics_update.reject { |_, v| v.empty? }
67
+ @first_run = false
68
+ diagnostics_update
69
+ end
70
+ end
71
+
72
+ def workspace_edit(diagnostics)
73
+ diagnostics = sanitize(diagnostics)
74
+ .select(&:correctable?)
75
+
76
+ {
77
+ documentChanges: document_changes(diagnostics),
78
+ }
79
+ end
80
+
81
+ def delete_applied(diagnostics)
82
+ diagnostics = sanitize(diagnostics)
83
+ .select(&:correctable?)
84
+
85
+ previous_paths = paths(@latest_diagnostics)
86
+
87
+ diagnostics.each do |diagnostic|
88
+ delete(diagnostic.relative_path, diagnostic)
89
+ end
90
+
91
+ current_paths = paths(@latest_diagnostics)
92
+
93
+ (current_paths + previous_paths).map do |path|
94
+ [path, @latest_diagnostics[path] || []]
95
+ end.to_h
96
+ end
97
+
98
+ private
99
+
100
+ def sanitize(diagnostics)
101
+ diagnostics = diagnostics.map { |hash| find(hash) }.reject(&:nil?) if diagnostics[0]&.is_a?(Hash)
102
+ diagnostics
103
+ end
104
+
105
+ def delete(relative_path, diagnostic)
106
+ relative_path = Pathname.new(relative_path) if relative_path.is_a?(String)
107
+ @mutex.synchronize do
108
+ @latest_diagnostics[relative_path]&.delete(diagnostic)
109
+ @latest_diagnostics.delete(relative_path) if @latest_diagnostics[relative_path]&.empty?
110
+ end
111
+ end
112
+
113
+ def find(diagnostic_hash)
114
+ diagnostics(diagnostic_hash.dig(:data, :relative_path))
115
+ .find { |d| d == diagnostic_hash }
116
+ end
117
+
118
+ def document_changes(diagnostics)
119
+ corrector = DocumentChangeCorrector.new
120
+ diagnostics.each do |diagnostic|
121
+ offense = diagnostic.offense
122
+ offense.correct(corrector)
123
+ end
124
+ corrector.document_changes
125
+ end
126
+
127
+ def paths(diagnostics)
128
+ (diagnostics || {}).keys.map { |path| Pathname.new(path) }.to_set
129
+ end
130
+
131
+ def single_file_diagnostics(relative_path)
132
+ @latest_diagnostics[relative_path]&.select(&:single_file?) || []
133
+ end
134
+ end
135
+ end
136
+ end