theme-check 1.10.3 → 1.11.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/.github/ISSUE_TEMPLATE/bug_report.md +29 -0
  3. data/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
  4. data/.github/workflows/cla.yml +22 -0
  5. data/.github/workflows/theme-check.yml +1 -1
  6. data/CHANGELOG.md +41 -0
  7. data/README.md +7 -8
  8. data/config/default.yml +4 -0
  9. data/data/shopify_liquid/filters.yml +18 -0
  10. data/dev.yml +1 -1
  11. data/docs/checks/asset_preload.md +60 -0
  12. data/docs/checks/asset_size_javascript.md +2 -2
  13. data/docs/checks/missing_enable_comment.md +3 -3
  14. data/docs/checks/nested_snippet.md +8 -8
  15. data/docs/checks/translation_key_exists.md +4 -4
  16. data/docs/checks/valid_html_translation.md +1 -1
  17. data/lib/theme_check/analyzer.rb +18 -3
  18. data/lib/theme_check/check.rb +6 -1
  19. data/lib/theme_check/checks/asset_preload.rb +20 -0
  20. data/lib/theme_check/checks/deprecated_filter.rb +29 -5
  21. data/lib/theme_check/checks/missing_enable_comment.rb +4 -0
  22. data/lib/theme_check/checks/missing_required_template_files.rb +5 -1
  23. data/lib/theme_check/checks/missing_template.rb +5 -1
  24. data/lib/theme_check/checks/unused_assign.rb +6 -1
  25. data/lib/theme_check/checks/unused_snippet.rb +50 -2
  26. data/lib/theme_check/config.rb +2 -2
  27. data/lib/theme_check/disabled_checks.rb +11 -4
  28. data/lib/theme_check/file_system_storage.rb +2 -0
  29. data/lib/theme_check/in_memory_storage.rb +1 -1
  30. data/lib/theme_check/language_server/bridge.rb +31 -6
  31. data/lib/theme_check/language_server/diagnostics_engine.rb +80 -34
  32. data/lib/theme_check/language_server/diagnostics_manager.rb +27 -6
  33. data/lib/theme_check/language_server/execute_command_providers/run_checks_execute_command_provider.rb +7 -6
  34. data/lib/theme_check/language_server/handler.rb +90 -8
  35. data/lib/theme_check/language_server/server.rb +42 -14
  36. data/lib/theme_check/language_server/versioned_in_memory_storage.rb +17 -2
  37. data/lib/theme_check/liquid_file.rb +22 -1
  38. data/lib/theme_check/liquid_node.rb +33 -1
  39. data/lib/theme_check/liquid_visitor.rb +1 -1
  40. data/lib/theme_check/schema_helper.rb +1 -1
  41. data/lib/theme_check/tags.rb +2 -1
  42. data/lib/theme_check/version.rb +1 -1
  43. data/theme-check.gemspec +2 -2
  44. metadata +10 -6
  45. data/.github/probots.yml +0 -3
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  require "set"
3
4
 
4
5
  module ThemeCheck
@@ -11,16 +12,22 @@ module ThemeCheck
11
12
  @used_snippets = Set.new
12
13
  end
13
14
 
14
- def on_include(node)
15
+ def on_render(node)
15
16
  if node.value.template_name_expr.is_a?(String)
16
17
  @used_snippets << "snippets/#{node.value.template_name_expr}"
18
+
19
+ elsif might_have_a_block_as_variable_lookup?(node)
20
+ # We ignore this case, because that's a "proper" use case for
21
+ # the render tag with OS 2.0
22
+ # {% render block %} shouldn't turn off the UnusedSnippet check
23
+
17
24
  else
18
25
  # Can't reliably track unused snippets if an expression is used, ignore this check
19
26
  @used_snippets.clear
20
27
  ignore!
21
28
  end
22
29
  end
23
- alias_method :on_render, :on_include
30
+ alias_method :on_include, :on_render
24
31
 
25
32
  def on_end
26
33
  missing_snippets.each do |theme_file|
@@ -33,5 +40,46 @@ module ThemeCheck
33
40
  def missing_snippets
34
41
  theme.snippets.reject { |t| @used_snippets.include?(t.name) }
35
42
  end
43
+
44
+ private
45
+
46
+ # This function returns true when the render node passed might have a
47
+ # variable lookup that refers to a block as template_name_expr.
48
+ #
49
+ # e.g.
50
+ #
51
+ # {% for block in col %}
52
+ # {% render block %}
53
+ # {% endfor %}
54
+ #
55
+ # In this case, the `block` variable_lookup in the render tag might be
56
+ # a Block because col might be an array of blocks.
57
+ #
58
+ # @param node [Node]
59
+ def might_have_a_block_as_variable_lookup?(node)
60
+ return false unless node.type_name == :render
61
+
62
+ return false unless node.value.template_name_expr.is_a?(Liquid::VariableLookup)
63
+
64
+ name = node.value.template_name_expr.name
65
+ return false unless name.is_a?(String)
66
+
67
+ # We're going through all the parents of the nodes until we find
68
+ # a For node with variable_name === to the template_name_expr's name
69
+ find_parent(node.parent) do |parent_node|
70
+ next false unless parent_node.type_name == :for
71
+
72
+ parent_node.value.variable_name == name
73
+ end
74
+ end
75
+
76
+ # @param node [Node]
77
+ def find_parent(node, &pred)
78
+ return nil unless node
79
+
80
+ return node if yield node
81
+
82
+ find_parent(node.parent, &pred)
83
+ end
36
84
  end
37
85
  end
@@ -126,14 +126,14 @@ module ThemeCheck
126
126
  options_for_check = options.transform_keys(&:to_sym)
127
127
  options_for_check.delete(:enabled)
128
128
  severity = options_for_check.delete(:severity)
129
- ignored_patterns = options_for_check.delete(:ignore) || []
129
+ check_ignored_patterns = options_for_check.delete(:ignore) || []
130
130
  check = if options_for_check.empty?
131
131
  check_class.new
132
132
  else
133
133
  check_class.new(**options_for_check)
134
134
  end
135
135
  check.severity = severity.to_sym if severity
136
- check.ignored_patterns = ignored_patterns
136
+ check.ignored_patterns = check_ignored_patterns + ignored_patterns
137
137
  check.options = options_for_check
138
138
  check
139
139
  end.compact
@@ -34,14 +34,16 @@ module ThemeCheck
34
34
  # We want to disable checks inside comments
35
35
  # (e.g. html checks inside {% comment %})
36
36
  disabled = @disabled_checks[[node.theme_file, :all]]
37
- disabled.start_index = node.inner_markup_start_index
38
- disabled.end_index = node.inner_markup_end_index
37
+ unless disabled.first_line
38
+ disabled.start_index = node.inner_markup_start_index
39
+ disabled.end_index = node.inner_markup_end_index
40
+ end
39
41
  end
40
42
  end
41
43
 
42
44
  def disabled?(check, theme_file, check_name, index)
43
45
  return true if check.ignored_patterns&.any? do |pattern|
44
- theme_file.relative_path.fnmatch?(pattern)
46
+ theme_file&.relative_path&.fnmatch?(pattern)
45
47
  end
46
48
 
47
49
  @disabled_checks[[theme_file, :all]]&.disabled?(index) ||
@@ -65,7 +67,12 @@ module ThemeCheck
65
67
  private
66
68
 
67
69
  def comment_text(node)
68
- node.value.nodelist.join
70
+ case node.type_name
71
+ when :comment
72
+ node.value.nodelist.join
73
+ when :inline_comment
74
+ node.markup.sub(/\s*#+\s*/, '')
75
+ end
69
76
  end
70
77
 
71
78
  def start_disabling?(text)
@@ -21,6 +21,8 @@ module ThemeCheck
21
21
 
22
22
  def read(relative_path)
23
23
  file(relative_path).read(mode: 'rb', encoding: 'UTF-8')
24
+ rescue Errno::ENOENT
25
+ nil
24
26
  end
25
27
 
26
28
  def write(relative_path, content)
@@ -9,7 +9,7 @@ module ThemeCheck
9
9
  attr_reader :root
10
10
 
11
11
  def initialize(files = {}, root = "/dev/null")
12
- @files = files
12
+ @files = files # Hash<String, String>
13
13
  @root = Pathname.new(root)
14
14
  end
15
15
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "timeout"
4
+
3
5
  # This class exists as a bridge (or boundary) between our handlers and the outside world.
4
6
  #
5
7
  # It is concerned with all the Language Server Protocol constructs. i.e.
@@ -29,6 +31,9 @@ module ThemeCheck
29
31
 
30
32
  # Whether the client supports work done progress notifications
31
33
  @supports_work_done_progress = false
34
+
35
+ @work_done_progress_mutex = Mutex.new
36
+ @work_done_progress_token = 1
32
37
  end
33
38
 
34
39
  def log(message)
@@ -78,12 +83,17 @@ module ThemeCheck
78
83
 
79
84
  # https://microsoft.github.io/language-server-protocol/specifications/specification-3-17/#responseError
80
85
  def send_internal_error(id, e)
86
+ # For a reason I can't comprehend, sometimes
87
+ # e.full_message _hangs_ and brings your CPU to 100%.
88
+ # It's wrapped in here because it prints anyway...
89
+ # This shit is weird, yo.
90
+ Timeout.timeout(1) do
91
+ $stderr.puts e.full_message
92
+ end
93
+ ensure
81
94
  send_response(id, nil, {
82
95
  code: ErrorCodes::INTERNAL_ERROR,
83
- message: <<~EOS,
84
- #{e.class}: #{e.message}
85
- #{e.backtrace.join("\n ")}
86
- EOS
96
+ message: "A theme-check-language-server has occured, inspect OUTPUT logs for details.",
87
97
  })
88
98
  end
89
99
 
@@ -103,15 +113,28 @@ module ThemeCheck
103
113
  @supports_work_done_progress
104
114
  end
105
115
 
106
- def send_create_work_done_progress_request(token)
107
- return unless supports_work_done_progress?
116
+ def send_create_work_done_progress_request
117
+ # This isn't necessary, but it kind of is to make it obvious
118
+ # that this variable is not thread safe. Don't try to refactor
119
+ # this with @work_done_progress_token because you're going to
120
+ # have a hard time.
121
+ token = @work_done_progress_mutex.synchronize do
122
+ @work_done_progress_token += 1
123
+ end
124
+
125
+ return token unless supports_work_done_progress?
126
+
127
+ # We're going to wait for a response here...
108
128
  send_request("window/workDoneProgress/create", {
109
129
  token: token,
110
130
  })
131
+
132
+ token
111
133
  end
112
134
 
113
135
  def send_work_done_progress_begin(token, title)
114
136
  return unless supports_work_done_progress?
137
+
115
138
  send_progress(token, {
116
139
  kind: 'begin',
117
140
  title: title,
@@ -122,6 +145,7 @@ module ThemeCheck
122
145
 
123
146
  def send_work_done_progress_report(token, message, percentage)
124
147
  return unless supports_work_done_progress?
148
+
125
149
  send_progress(token, {
126
150
  kind: 'report',
127
151
  message: message,
@@ -132,6 +156,7 @@ module ThemeCheck
132
156
 
133
157
  def send_work_done_progress_end(token, message)
134
158
  return unless supports_work_done_progress?
159
+
135
160
  send_progress(token, {
136
161
  kind: 'end',
137
162
  message: message,
@@ -12,57 +12,103 @@ module ThemeCheck
12
12
  @diagnostics_manager = diagnostics_manager
13
13
  @storage = storage
14
14
  @bridge = bridge
15
- @token = 0
16
15
  end
17
16
 
18
17
  def first_run?
19
18
  @diagnostics_manager.first_run?
20
19
  end
21
20
 
22
- def analyze_and_send_offenses(absolute_path, config, force: false, only_single_file: false)
21
+ def analyze_and_send_offenses(absolute_path_or_paths, config, force: false, only_single_file: false)
23
22
  return unless @diagnostics_lock.try_lock
24
- @token += 1
25
- @bridge.send_create_work_done_progress_request(@token)
23
+
26
24
  theme = ThemeCheck::Theme.new(storage)
27
25
  analyzer = ThemeCheck::Analyzer.new(theme, config.enabled_checks)
28
26
 
29
- if (!only_single_file && @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
- @bridge.log(end_message)
41
- send_diagnostics(offenses)
27
+ if !only_single_file && (@diagnostics_manager.first_run? || force)
28
+ run_full_theme_check(analyzer)
42
29
  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], only_single_file: only_single_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, [relative_path], only_single_file: only_single_file)
59
- end
30
+ run_partial_theme_check(absolute_path_or_paths, theme, analyzer, only_single_file)
31
+ end
32
+
33
+ @diagnostics_lock.unlock
34
+ end
35
+
36
+ def clear_diagnostics(relative_paths)
37
+ return unless @diagnostics_lock.try_lock
38
+
39
+ as_array(relative_paths).each do |relative_path|
40
+ send_clearing_diagnostics(relative_path)
60
41
  end
42
+
61
43
  @diagnostics_lock.unlock
62
44
  end
63
45
 
64
46
  private
65
47
 
48
+ def run_full_theme_check(analyzer)
49
+ raise 'Unsafe operation' unless @diagnostics_lock.owned?
50
+
51
+ token = @bridge.send_create_work_done_progress_request
52
+ @bridge.send_work_done_progress_begin(token, "Full theme check")
53
+ @bridge.log("Checking #{storage.root}")
54
+ offenses = nil
55
+ time = Benchmark.measure do
56
+ offenses = analyzer.analyze_theme do |path, i, total|
57
+ @bridge.send_work_done_progress_report(token, "#{i}/#{total} #{path}", (i.to_f / total * 100.0).to_i)
58
+ end
59
+ end
60
+ end_message = "Found #{offenses.size} offenses in #{format("%0.2f", time.real)}s"
61
+ @bridge.send_work_done_progress_end(token, end_message)
62
+ @bridge.log(end_message)
63
+ send_diagnostics(offenses)
64
+ end
65
+
66
+ def run_partial_theme_check(absolute_path_or_paths, theme, analyzer, only_single_file)
67
+ raise 'Unsafe operation' unless @diagnostics_lock.owned?
68
+
69
+ relative_paths = as_array(absolute_path_or_paths).map do |absolute_path|
70
+ Pathname.new(storage.relative_path(absolute_path))
71
+ end
72
+
73
+ theme_files = relative_paths
74
+ .map { |relative_path| theme[relative_path] }
75
+ .reject(&:nil?)
76
+
77
+ deleted_relative_paths = relative_paths - theme_files.map(&:relative_path)
78
+ deleted_relative_paths
79
+ .each { |p| send_clearing_diagnostics(p) }
80
+
81
+ token = @bridge.send_create_work_done_progress_request
82
+ @bridge.send_work_done_progress_begin(token, "Partial theme check")
83
+ offenses = nil
84
+ time = Benchmark.measure do
85
+ offenses = analyzer.analyze_files(theme_files, only_single_file: only_single_file) do |path, i, total|
86
+ @bridge.send_work_done_progress_report(token, "#{i}/#{total} #{path}", (i.to_f / total * 100.0).to_i)
87
+ end
88
+ end
89
+ end_message = "Found #{offenses.size} new offenses in #{format("%0.2f", time.real)}s"
90
+ @bridge.send_work_done_progress_end(token, end_message)
91
+ @bridge.log(end_message)
92
+ send_diagnostics(offenses, theme_files.map(&:relative_path), only_single_file: only_single_file)
93
+ end
94
+
95
+ def send_clearing_diagnostics(relative_path)
96
+ raise 'Unsafe operation' unless @diagnostics_lock.owned?
97
+
98
+ relative_path = Pathname.new(relative_path) unless relative_path.is_a?(Pathname)
99
+ @diagnostics_manager.clear_diagnostics(relative_path)
100
+ send_diagnostic(relative_path, DiagnosticsManager::NO_DIAGNOSTICS)
101
+ end
102
+
103
+ def as_array(maybe_array)
104
+ case maybe_array
105
+ when Array
106
+ maybe_array
107
+ else
108
+ [maybe_array]
109
+ end
110
+ end
111
+
66
112
  def send_diagnostics(offenses, analyzed_files = nil, only_single_file: false)
67
113
  @diagnostics_manager.build_diagnostics(
68
114
  offenses,
@@ -4,6 +4,11 @@ require "logger"
4
4
  module ThemeCheck
5
5
  module LanguageServer
6
6
  class DiagnosticsManager
7
+ # The empty array is used in the protocol to mean that no
8
+ # diagnostics exist for this file. It's not always evident when
9
+ # reading code.
10
+ NO_DIAGNOSTICS = [].freeze
11
+
7
12
  # This class exists to facilitate LanguageServer diagnostics tracking.
8
13
  #
9
14
  # Motivations:
@@ -49,27 +54,27 @@ module ThemeCheck
49
54
  # When doing single file checks, we keep the whole theme old
50
55
  # ones and accept the new single ones
51
56
  if only_single_file && analyzed_paths.include?(path)
52
- single_file_diagnostics = current_diagnostics[path] || []
53
- whole_theme_diagnostics = whole_theme_diagnostics(path) || []
57
+ single_file_diagnostics = current_diagnostics[path] || NO_DIAGNOSTICS
58
+ whole_theme_diagnostics = whole_theme_diagnostics(path) || NO_DIAGNOSTICS
54
59
  [path, single_file_diagnostics + whole_theme_diagnostics]
55
60
 
56
61
  # If doing single file checks that are not in the
57
62
  # analyzed_paths array then we just keep the old
58
63
  # diagnostics
59
64
  elsif only_single_file
60
- [path, previous_diagnostics(path) || []]
65
+ [path, previous_diagnostics(path) || NO_DIAGNOSTICS]
61
66
 
62
67
  # When doing a full_check, we either send the current
63
68
  # diagnostics or an empty array to clear the diagnostics
64
69
  # for that file.
65
70
  elsif full_check
66
- [path, current_diagnostics[path] || []]
71
+ [path, current_diagnostics[path] || NO_DIAGNOSTICS]
67
72
 
68
73
  # When doing a partial check, the single file diagnostics
69
74
  # from the previous runs should be sent. Otherwise the
70
75
  # latest results are the good ones.
71
76
  else
72
- new_diagnostics = current_diagnostics[path] || []
77
+ new_diagnostics = current_diagnostics[path] || NO_DIAGNOSTICS
73
78
  should_use_cached_results = !analyzed_paths.include?(path)
74
79
  old_diagnostics = should_use_cached_results ? single_file_diagnostics(path) : []
75
80
  [path, new_diagnostics + old_diagnostics]
@@ -113,6 +118,13 @@ module ThemeCheck
113
118
  end.to_h
114
119
  end
115
120
 
121
+ # For when you know there shouldn't be anything on that file
122
+ # anymore. (e.g. file delete or file rename)
123
+ def clear_diagnostics(relative_path)
124
+ relative_path = sanitize_path(relative_path)
125
+ @latest_diagnostics.delete(relative_path)
126
+ end
127
+
116
128
  private
117
129
 
118
130
  def sanitize(diagnostics)
@@ -120,8 +132,17 @@ module ThemeCheck
120
132
  diagnostics
121
133
  end
122
134
 
135
+ def sanitize_path(relative_path)
136
+ case relative_path
137
+ when String
138
+ Pathname.new(relative_path)
139
+ else
140
+ relative_path
141
+ end
142
+ end
143
+
123
144
  def delete(relative_path, diagnostic)
124
- relative_path = Pathname.new(relative_path) if relative_path.is_a?(String)
145
+ relative_path = sanitize_path(relative_path)
125
146
  @mutex.synchronize do
126
147
  @latest_diagnostics[relative_path]&.delete(diagnostic)
127
148
  @latest_diagnostics.delete(relative_path) if @latest_diagnostics[relative_path]&.empty?
@@ -7,17 +7,18 @@ module ThemeCheck
7
7
 
8
8
  command "runChecks"
9
9
 
10
- def initialize(diagnostics_engine, root_path, root_config)
10
+ def initialize(diagnostics_engine, storage, linter_config, language_server_config)
11
11
  @diagnostics_engine = diagnostics_engine
12
- @root_path = root_path
13
- @root_config = root_config
12
+ @storage = storage
13
+ @linter_config = linter_config
14
+ @language_server_config = language_server_config
14
15
  end
15
16
 
16
17
  def execute(_args)
17
18
  @diagnostics_engine.analyze_and_send_offenses(
18
- @root_path,
19
- @root_config,
20
- only_single_file: false,
19
+ @storage.opened_files.map { |relative_path| @storage.path(relative_path) },
20
+ @linter_config,
21
+ only_single_file: @language_server_config.only_single_file?,
21
22
  force: true
22
23
  )
23
24
  nil
@@ -12,6 +12,16 @@ module ThemeCheck
12
12
  version: ThemeCheck::VERSION,
13
13
  }
14
14
 
15
+ # https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#fileOperationFilter
16
+ FILE_OPERATION_FILTER = {
17
+ filters: [{
18
+ scheme: 'file',
19
+ pattern: {
20
+ glob: '**/*',
21
+ },
22
+ }],
23
+ }
24
+
15
25
  CAPABILITIES = {
16
26
  completionProvider: {
17
27
  triggerCharacters: ['.', '{{ ', '{% '],
@@ -33,6 +43,13 @@ module ThemeCheck
33
43
  willSave: false,
34
44
  save: true,
35
45
  },
46
+ workspace: {
47
+ fileOperations: {
48
+ didCreate: FILE_OPERATION_FILTER,
49
+ didDelete: FILE_OPERATION_FILTER,
50
+ willRename: FILE_OPERATION_FILTER,
51
+ },
52
+ },
36
53
  }
37
54
 
38
55
  def initialize(bridge)
@@ -55,7 +72,12 @@ module ThemeCheck
55
72
  @diagnostics_engine = DiagnosticsEngine.new(@storage, @bridge, @diagnostics_manager)
56
73
  @execute_command_engine = ExecuteCommandEngine.new
57
74
  @execute_command_engine << CorrectionExecuteCommandProvider.new(@storage, @bridge, @diagnostics_manager)
58
- @execute_command_engine << RunChecksExecuteCommandProvider.new(@diagnostics_engine, @root_path, config_for_path(@root_path))
75
+ @execute_command_engine << RunChecksExecuteCommandProvider.new(
76
+ @diagnostics_engine,
77
+ @storage,
78
+ config_for_path(@root_path),
79
+ @configuration,
80
+ )
59
81
  @code_action_engine = CodeActionEngine.new(@storage, @diagnostics_manager)
60
82
  @bridge.send_response(id, {
61
83
  capabilities: CAPABILITIES,
@@ -65,6 +87,7 @@ module ThemeCheck
65
87
 
66
88
  def on_initialized(_id, _params)
67
89
  return unless @configuration
90
+
68
91
  @configuration.fetch
69
92
  @configuration.register_did_change_capability
70
93
  end
@@ -91,9 +114,17 @@ module ThemeCheck
91
114
 
92
115
  def on_text_document_did_close(_id, params)
93
116
  relative_path = relative_path_from_text_document_uri(params)
94
- file_system_content = Pathname.new(text_document_uri(params)).read(mode: 'rb', encoding: 'UTF-8')
95
- # On close, the file system becomes the source of truth
96
- @storage.write(relative_path, file_system_content, nil)
117
+ begin
118
+ file_system_content = Pathname.new(text_document_uri(params)).read(mode: 'rb', encoding: 'UTF-8')
119
+ # On close, the file system becomes the source of truth
120
+ @storage.write(relative_path, file_system_content, nil)
121
+
122
+ # the file no longer exists because either the user deleted it, or the user renamed it.
123
+ rescue Errno::ENOENT
124
+ @storage.remove(relative_path)
125
+ ensure
126
+ @diagnostics_engine.clear_diagnostics(relative_path) if @configuration.only_single_file?
127
+ end
97
128
  end
98
129
 
99
130
  def on_text_document_did_save(_id, params)
@@ -125,6 +156,52 @@ module ThemeCheck
125
156
  ))
126
157
  end
127
158
 
159
+ def on_workspace_did_create_files(_id, params)
160
+ paths = params[:files]
161
+ &.map { |file| file[:uri] }
162
+ &.map { |uri| file_path(uri) }
163
+ return unless paths
164
+
165
+ paths.each do |path|
166
+ relative_path = @storage.relative_path(path)
167
+ file_system_content = Pathname.new(path).read(mode: 'rb', encoding: 'UTF-8')
168
+ @storage.write(relative_path, file_system_content, nil)
169
+ end
170
+ end
171
+
172
+ def on_workspace_did_delete_files(_id, params)
173
+ absolute_paths = params[:files]
174
+ &.map { |file| file[:uri] }
175
+ &.map { |uri| file_path(uri) }
176
+ return unless absolute_paths
177
+
178
+ absolute_paths.each do |path|
179
+ relative_path = @storage.relative_path(path)
180
+ @storage.remove(relative_path)
181
+ end
182
+
183
+ analyze_and_send_offenses(absolute_paths)
184
+ end
185
+
186
+ # We're using workspace/willRenameFiles here because we want this to run
187
+ # before textDocument/didOpen and textDocumetn/didClose of the files
188
+ # (which might trigger another theme analysis).
189
+ def on_workspace_will_rename_files(id, params)
190
+ relative_paths = params[:files]
191
+ &.map { |file| [file[:oldUri], file[:newUri]] }
192
+ &.map { |(old_uri, new_uri)| [relative_path_from_uri(old_uri), relative_path_from_uri(new_uri)] }
193
+ return @bridge.send_response(id, nil) unless relative_paths
194
+
195
+ relative_paths.each do |(old_path, new_path)|
196
+ @storage.write(new_path, @storage.read(old_path), nil)
197
+ @storage.remove(old_path)
198
+ end
199
+ @bridge.send_response(id, nil)
200
+
201
+ absolute_paths = relative_paths.flatten(2).map { |p| @storage.path(p) }
202
+ analyze_and_send_offenses(absolute_paths)
203
+ end
204
+
128
205
  def on_workspace_execute_command(id, params)
129
206
  @bridge.send_response(id, @execute_command_engine.execute(
130
207
  params[:command],
@@ -159,6 +236,10 @@ module ThemeCheck
159
236
  file_path(params.dig(:textDocument, :uri))
160
237
  end
161
238
 
239
+ def relative_path_from_uri(uri)
240
+ @storage.relative_path(file_path(uri))
241
+ end
242
+
162
243
  def relative_path_from_text_document_uri(params)
163
244
  @storage.relative_path(text_document_uri(params))
164
245
  end
@@ -185,15 +266,16 @@ module ThemeCheck
185
266
  params.dig(:contentChanges, 0, :text)
186
267
  end
187
268
 
188
- def config_for_path(path)
269
+ def config_for_path(path_or_paths)
270
+ path = path_or_paths.is_a?(Array) ? path_or_paths[0] : path_or_paths
189
271
  root = ThemeCheck::Config.find(path) || @root_path
190
272
  ThemeCheck::Config.from_path(root)
191
273
  end
192
274
 
193
- def analyze_and_send_offenses(absolute_path, only_single_file: nil)
275
+ def analyze_and_send_offenses(absolute_path_or_paths, only_single_file: nil)
194
276
  @diagnostics_engine.analyze_and_send_offenses(
195
- absolute_path,
196
- config_for_path(absolute_path),
277
+ absolute_path_or_paths,
278
+ config_for_path(absolute_path_or_paths),
197
279
  only_single_file: only_single_file.nil? ? @configuration.only_single_file? : only_single_file
198
280
  )
199
281
  end