canopus 0.4.0 → 0.5.0

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 (137) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +191 -19
  4. data/assets/keymaps/emacs.jsonc +5 -0
  5. data/assets/keymaps/jetbrains.jsonc +5 -0
  6. data/assets/keymaps/sublime.jsonc +5 -0
  7. data/assets/keymaps/vscode.jsonc +5 -0
  8. data/docs/debugging.md +143 -0
  9. data/docs/distribution.md +40 -9
  10. data/docs/lsp.md +131 -0
  11. data/docs/performance.md +18 -0
  12. data/docs/providers.md +36 -0
  13. data/docs/release-log.md +12 -0
  14. data/docs/tasks.md +64 -0
  15. data/docs/testing.md +49 -0
  16. data/docs/vim.md +3 -0
  17. data/exe/canopus +4 -1
  18. data/lib/canopus/buffer.rb +291 -50
  19. data/lib/canopus/cli.rb +5 -2
  20. data/lib/canopus/command.rb +13 -4
  21. data/lib/canopus/controller.rb +183 -40
  22. data/lib/canopus/debug/breakpoints.rb +550 -0
  23. data/lib/canopus/debug/configuration.rb +291 -0
  24. data/lib/canopus/debug/console.rb +243 -0
  25. data/lib/canopus/debug/panel.rb +487 -0
  26. data/lib/canopus/debug/session.rb +354 -0
  27. data/lib/canopus/decoration.rb +4 -2
  28. data/lib/canopus/denebola_compat.rb +27 -0
  29. data/lib/canopus/diagnostics.rb +173 -0
  30. data/lib/canopus/display_map/overlay_map.rb +6 -2
  31. data/lib/canopus/editor/snippet_expandable.rb +3 -2
  32. data/lib/canopus/editor.rb +122 -31
  33. data/lib/canopus/git/state.rb +100 -0
  34. data/lib/canopus/language/background_analysis.rb +93 -14
  35. data/lib/canopus/language/document.rb +70 -34
  36. data/lib/canopus/language/symbol.rb +1 -0
  37. data/lib/canopus/language/syntax_worker.rb +61 -9
  38. data/lib/canopus/language.rb +3 -0
  39. data/lib/canopus/minimap.rb +201 -0
  40. data/lib/canopus/patch.rb +3 -1
  41. data/lib/canopus/plugins/api.rb +4 -2
  42. data/lib/canopus/plugins/isolated_runtime.rb +64 -7
  43. data/lib/canopus/plugins/registry.rb +114 -3
  44. data/lib/canopus/plugins.rb +8 -7
  45. data/lib/canopus/project.rb +15 -6
  46. data/lib/canopus/provider.rb +192 -0
  47. data/lib/canopus/regexp_compat.rb +36 -4
  48. data/lib/canopus/selection.rb +12 -0
  49. data/lib/canopus/settings/schema.rb +201 -0
  50. data/lib/canopus/settings.rb +328 -49
  51. data/lib/canopus/task/configuration.rb +356 -0
  52. data/lib/canopus/task/problem_matcher.rb +264 -0
  53. data/lib/canopus/task/runner.rb +314 -0
  54. data/lib/canopus/test_runner/bounded_ignore.rb +144 -0
  55. data/lib/canopus/test_runner/discovery.rb +97 -0
  56. data/lib/canopus/test_runner/execution.rb +194 -0
  57. data/lib/canopus/test_runner/minitest.rb +116 -0
  58. data/lib/canopus/test_runner/rspec.rb +103 -0
  59. data/lib/canopus/test_runner/safe_walker.rb +37 -0
  60. data/lib/canopus/test_runner.rb +54 -0
  61. data/lib/canopus/theme/import.rb +131 -0
  62. data/lib/canopus/theme.rb +28 -4
  63. data/lib/canopus/version.rb +1 -1
  64. data/lib/canopus/vim/motionable.rb +1 -1
  65. data/lib/canopus/vim.rb +10 -16
  66. data/lib/canopus/workspace/auto_savable.rb +83 -0
  67. data/lib/canopus/workspace/debug_aware.rb +600 -0
  68. data/lib/canopus/workspace/edit/plan.rb +2 -2
  69. data/lib/canopus/workspace/editor_configurable.rb +113 -0
  70. data/lib/canopus/workspace/file_change_aware.rb +11 -3
  71. data/lib/canopus/workspace/git_aware.rb +80 -34
  72. data/lib/canopus/workspace/git_blame.rb +175 -0
  73. data/lib/canopus/workspace/git_conflict_resolution.rb +410 -0
  74. data/lib/canopus/workspace/git_diff_view.rb +641 -0
  75. data/lib/canopus/workspace/git_history.rb +171 -0
  76. data/lib/canopus/workspace/git_remote.rb +267 -0
  77. data/lib/canopus/workspace/git_staging.rb +583 -0
  78. data/lib/canopus/workspace/hierarchy_aware.rb +477 -0
  79. data/lib/canopus/workspace/keymap_aware.rb +66 -0
  80. data/lib/canopus/workspace/language_aware.rb +3721 -135
  81. data/lib/canopus/workspace/language_server_configurable.rb +287 -71
  82. data/lib/canopus/workspace/persistent_undo.rb +169 -0
  83. data/lib/canopus/workspace/problems_aware.rb +131 -0
  84. data/lib/canopus/workspace/project_searchable.rb +88 -11
  85. data/lib/canopus/workspace/project_tree_editable.rb +3 -1
  86. data/lib/canopus/workspace/recoverable.rb +204 -0
  87. data/lib/canopus/workspace/session_persistable.rb +85 -13
  88. data/lib/canopus/workspace/settings_aware.rb +76 -2
  89. data/lib/canopus/workspace/task_aware.rb +194 -0
  90. data/lib/canopus/workspace/test_aware.rb +394 -0
  91. data/lib/canopus/workspace/trust.rb +73 -0
  92. data/lib/canopus/workspace/view/terminal_presentable.rb +298 -47
  93. data/lib/canopus/workspace/view.rb +399 -74
  94. data/lib/canopus/workspace.rb +688 -44
  95. data/lib/canopus.rb +20 -2
  96. data/sig/canopus.rbs +138 -31
  97. data/sig/controller.rbs +10 -3
  98. data/sig/debug.rbs +121 -0
  99. data/sig/decoration.rbs +12 -2
  100. data/sig/diagnostics.rbs +26 -0
  101. data/sig/minimap.rbs +20 -0
  102. data/sig/provider.rbs +29 -0
  103. data/sig/services.rbs +1 -16
  104. data/sig/task.rbs +92 -0
  105. data/sig/test_runner.rbs +109 -0
  106. data/sig/trust.rbs +17 -0
  107. data/sig/workspace_edits.rbs +2 -2
  108. data/sig/workspace_services.rbs +234 -2
  109. data/tools/check_dependencies.rb +21 -2
  110. data/tools/package.rb +172 -14
  111. data/tools/package_signature_test.rb +33 -0
  112. data/tools/package_test.rb +29 -0
  113. data/tools/sign_package.rb +123 -0
  114. data/tools/update.rb +189 -0
  115. data/tools/update_test.rb +71 -0
  116. metadata +206 -31
  117. data/lib/canopus/lsp/client.rb +0 -307
  118. data/lib/canopus/lsp/error.rb +0 -7
  119. data/lib/canopus/lsp/future/subscription.rb +0 -9
  120. data/lib/canopus/lsp/future.rb +0 -87
  121. data/lib/canopus/lsp/protocol.rb +0 -83
  122. data/lib/canopus/lsp/server_error.rb +0 -13
  123. data/lib/canopus/lsp/timeout.rb +0 -7
  124. data/lib/canopus/lsp/transport.rb +0 -123
  125. data/lib/canopus/lsp.rb +0 -19
  126. data/lib/canopus/project/search.rb +0 -164
  127. data/lib/canopus/project/search_worker/cancelled.rb +0 -3
  128. data/lib/canopus/project/search_worker/runner.rb +0 -9
  129. data/lib/canopus/project/search_worker.rb +0 -108
  130. data/lib/canopus/terminal/cell.rb +0 -7
  131. data/lib/canopus/terminal/grid.rb +0 -321
  132. data/lib/canopus/terminal/pty.rb +0 -178
  133. data/lib/canopus/terminal/scrollback.rb +0 -38
  134. data/lib/canopus/terminal/vt.rb +0 -398
  135. data/lib/canopus/terminal.rb +0 -13
  136. data/sig/lsp.rbs +0 -99
  137. data/sig/terminal.rbs +0 -130
@@ -2,14 +2,55 @@
2
2
 
3
3
  module Canopus
4
4
  module Workspace::LanguageServerConfigurable
5
+ def language_server_states(language)
6
+ options = server_options_list(@client_options&.[](language))
7
+ language_client_list(language).each_with_index.map do |client, index|
8
+ command = options.dig(index, :command) || []
9
+ {language: language, index: index, name: File.basename(command.first.to_s), state: client.state}.freeze
10
+ end.freeze
11
+ end
12
+
13
+ def show_language_server_restart
14
+ language = editor.language_document.definition.name
15
+ items = language_server_states(language)
16
+ self.palette = {kind: :language_servers, query: +"", index: 0,
17
+ matches: items.map { |item| "#{item[:name]} (#{item[:state]})" }, items: items}
18
+ update_palette
19
+ end
20
+
21
+ def restart_language_server(language, index)
22
+ (@client_lock ||= Mutex.new).synchronize do
23
+ options = server_options_list(@client_options&.[](language) || language_server_options(language))
24
+ clients = language_client_list(language).dup
25
+ option = options.fetch(index)
26
+ current = clients.fetch(index)
27
+ replacement = start_language_client(language, option, index: index)
28
+ replacements = clients.dup
29
+ replacements[index] = replacement
30
+ store_language_clients(language, replacements)
31
+ begin
32
+ open_language_client_documents(language, replacement)
33
+ rescue StandardError
34
+ store_language_clients(language, clients)
35
+ cleanup_language_clients(language, [replacement])
36
+ raise
37
+ end
38
+ retire_language_client(language, current)
39
+ @message = "Restarted #{File.basename(option.fetch(:command).first)}"
40
+ replacement
41
+ end
42
+ end
43
+
5
44
  private
6
45
 
7
46
  def normalize_server_options(value)
8
- value = {"command" => value} if value.is_a?(Array)
9
- unless value.is_a?(Hash) && (value.keys - %w[command env initialization_options configuration]).empty?
47
+ legacy = value.is_a?(Array)
48
+ value = {"command" => value} if legacy
49
+ allowed = %w[command env initialization_options configuration features]
50
+ unless value.is_a?(Hash) && (value.keys - allowed).empty?
10
51
  raise Error, "language server must be an argument array or command/options object"
11
52
  end
12
- command, env = value["command"], value.fetch("env", {})
53
+ command, env, features = value["command"], value.fetch("env", {}), value["features"]
13
54
  unless command.is_a?(Array) && !command.empty? && command.first.is_a?(String) && !command.first.empty? &&
14
55
  command.all? { |part| part.is_a?(String) && part.valid_encoding? && !part.include?("\0") }
15
56
  raise Error, "language server command must be a nonempty argument array without NUL bytes"
@@ -19,6 +60,10 @@ module Canopus
19
60
  end
20
61
  configuration = value.fetch("configuration", {})
21
62
  raise Error, "language server configuration must be an object" unless configuration.is_a?(Hash)
63
+ if features && (!features.is_a?(Array) || features.empty? || features.length > Workspace::LANGUAGE_SERVER_CAPABILITIES.length ||
64
+ features.uniq.length != features.length || !features.all? { |feature| Workspace::LANGUAGE_SERVER_CAPABILITIES.key?(feature) })
65
+ raise Error, "language server features must be unique supported feature names"
66
+ end
22
67
  encoded = JSON.generate(value)
23
68
  raise Error, "language server options exceed 1 MiB" if encoded.bytesize > 1 << 20
24
69
  snapshot = JSON.parse(encoded)
@@ -29,11 +74,59 @@ module Canopus
29
74
  end
30
75
  freeze_value.call(snapshot)
31
76
  {command: snapshot.fetch("command"), env: snapshot.fetch("env", {}).freeze,
32
- initialization_options: snapshot["initialization_options"], configuration: snapshot.fetch("configuration", {}).freeze}.freeze
33
- rescue JSON::GeneratorError, JSON::ParserError => error
77
+ initialization_options: snapshot["initialization_options"], configuration: snapshot.fetch("configuration", {}).freeze,
78
+ features: snapshot["features"], legacy: legacy}.freeze
79
+ rescue JSON::GeneratorError, JSON::ParserError, JSON::NestingError => error
34
80
  raise Error, "invalid language server options: #{error.message}"
35
81
  end
36
82
 
83
+ def normalize_server_configuration(value)
84
+ if value.is_a?(Array) && value.any? { |item| item.is_a?(Hash) }
85
+ unless value.length.between?(1, 16) && value.all? { |item| item.is_a?(Hash) }
86
+ raise Error, "language server list must contain 1 to 16 option objects"
87
+ end
88
+ return value.map { |item| normalize_server_options(item) }.freeze
89
+ end
90
+ normalize_server_options(value)
91
+ end
92
+
93
+ def server_options_list(options) = options.nil? ? [] : options.is_a?(Array) ? options : [options]
94
+
95
+ def language_client_list(language)
96
+ return @language_clients[language].compact if @language_clients&.key?(language)
97
+ client = @clients[language]
98
+ client ? [client] : []
99
+ end
100
+
101
+ def language_client_active?(client)
102
+ @language_clients ? @language_clients.values.any? { |clients| clients.include?(client) } : @clients.value?(client)
103
+ end
104
+
105
+ def language_server_feature?(option, client, feature)
106
+ return true unless option
107
+ return option[:features].include?(feature) if option[:features]
108
+ return true if option[:legacy]
109
+
110
+ capability = Workspace::LANGUAGE_SERVER_CAPABILITIES.fetch(feature)
111
+ capability.nil? || !!client.capabilities[capability]
112
+ end
113
+
114
+ def language_server_option(language, client)
115
+ index = language_client_list(language).index(client)
116
+ index && server_options_list(@client_options&.[](language))[index]
117
+ end
118
+
119
+ def store_language_clients(language, clients)
120
+ @language_clients ||= {}
121
+ if clients.empty?
122
+ @language_clients.delete(language)
123
+ @clients.delete(language)
124
+ else
125
+ @language_clients[language] = clients.freeze
126
+ @clients[language] = clients.first
127
+ end
128
+ end
129
+
37
130
  def language_server_options(language)
38
131
  values = @settings.for_language(language)
39
132
  configured = values["language_servers"][language]
@@ -41,16 +134,14 @@ module Canopus
41
134
  definition = @languages[language] || Language::DEFINITIONS.find { |item| item.name == language }
42
135
  configured = definition&.servers&.find { |candidate| Language.executable?(candidate.first) }
43
136
  end
44
- normalize_server_options(configured) unless configured.nil?
137
+ normalize_server_configuration(configured) unless configured.nil?
45
138
  end
46
139
 
47
140
  def language_server_settings_plan
48
- # Validate every layer before any client is stopped, including settings
49
- # for languages which have not opened a server yet.
50
141
  [@settings.values, *@settings["languages"].values].each do |layer|
51
142
  layer.fetch("language_servers", {}).each do |name, value|
52
143
  raise Error, "language server names must be strings" unless name.is_a?(String)
53
- normalize_server_options(value) unless value.nil?
144
+ normalize_server_configuration(value) unless value.nil?
54
145
  end
55
146
  end
56
147
  (@client_options || {}).keys.to_h { |language| [language, language_server_options(language)] }.freeze
@@ -81,52 +172,136 @@ module Canopus
81
172
  end
82
173
  end
83
174
 
84
- def ensure_language_server(language, options)
175
+ def ensure_language_server(language, options, timeout: nil)
85
176
  raise Error, "Workspace closed" if @closed
177
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout if timeout
86
178
  @client_options ||= {}
87
- previous, client = @client_options[language], @clients[language]
88
- if client && previous && options && previous.except(:configuration) == options.except(:configuration)
89
- client.notify("workspace/didChangeConfiguration", {settings: options[:configuration]}) if previous[:configuration] != options[:configuration]
90
- @client_options[language] = options
91
- return client
92
- end
93
- return if !client && previous == options && options.nil?
94
- if client
95
- @clients.delete(language)
96
- (@retired_language_clients ||= ObjectSpace::WeakMap.new)[client] = true
97
- @opened_lsp_documents&.keys&.each do |key|
98
- next unless key.first.equal?(client)
99
- @opened_lsp_documents.delete(key)
100
- client.close_document(LSP::Protocol.uri(key.last.path)) if key.last.path
101
- rescue LSP::Error
102
- nil
179
+ previous = server_options_list(@client_options[language])
180
+ requested = server_options_list(options)
181
+ clients = language_client_list(language)
182
+ reusable = clients.length == requested.length && previous.length == requested.length &&
183
+ requested.each_index.all? do |index|
184
+ previous[index].except(:configuration, :features, :legacy) == requested[index].except(:configuration, :features, :legacy)
103
185
  end
104
- client.stop
105
- post do
106
- @semantic_styles&.delete_if { |buffer, _| definition_for(buffer.path).name == language }
107
- @panes.flat_map(&:editors).each do |current|
108
- next unless current.language_document.definition.name == language
109
- map = current.display_map
110
- map.block_map.blocks.keys.each { |id| map.remove_block(id) if id.is_a?(Array) && id.first == :inlay }
111
- end
112
- self.palette = nil if @palette&.dig(:client).equal?(client)
113
- @hover_card = nil
114
- @window&.request_frame
186
+ if reusable
187
+ changed_features = requested.each_index.select do |index|
188
+ previous[index].values_at(:features, :legacy) != requested[index].values_at(:features, :legacy)
189
+ end
190
+ requested.each_index do |index|
191
+ clients[index].did_change_configuration(requested[index][:configuration]) if previous[index][:configuration] != requested[index][:configuration]
192
+ end
193
+ @client_options[language] = options
194
+ store_language_clients(language, clients)
195
+ changed_features.each do |index|
196
+ clear_diagnostics = language_server_feature?(previous[index], clients[index], "diagnostics") &&
197
+ !language_server_feature?(requested[index], clients[index], "diagnostics")
198
+ invalidate_language_client_features(language, clients[index], clear_diagnostics: clear_diagnostics)
115
199
  end
200
+ return @clients[language]
116
201
  end
202
+ return if clients.empty? && @client_options[language] == options && options.nil?
203
+
204
+ store_language_clients(language, [])
205
+ retire_language_clients(language, clients)
117
206
  if Thread.current.equal?(@language_reload_job)
118
207
  options = @language_reload_lock.synchronize do
119
208
  @language_reload_targets&.key?(language) ? @language_reload_targets[language] : options
120
209
  end
210
+ requested = server_options_list(options)
121
211
  end
122
- @client_options[language] = options
123
- return unless options
212
+ replacements = []
213
+ begin
214
+ requested.each_with_index do |option, index|
215
+ remaining = deadline && deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
216
+ raise Sadr::Timeout, "language server startup timed out" if remaining && remaining <= 0
217
+ replacements << start_language_client(language, option, index: index, timeout: remaining)
218
+ end
219
+ @client_options[language] = options
220
+ store_language_clients(language, replacements)
221
+ replacements.each { |client| open_language_client_documents(language, client) }
222
+ rescue StandardError
223
+ store_language_clients(language, [])
224
+ cleanup_language_clients(language, replacements)
225
+ raise
226
+ end
227
+ replacements.first
228
+ end
229
+
230
+ def retire_language_clients(language, clients)
231
+ failure = nil
232
+ clients.each do |client|
233
+ retire_language_client(language, client)
234
+ rescue StandardError => error
235
+ failure ||= error
236
+ end
237
+ raise failure if failure
238
+ end
239
+
240
+ def cleanup_language_clients(language, clients)
241
+ clients.each do |client|
242
+ retire_language_client(language, client)
243
+ rescue StandardError
244
+ begin
245
+ stop_language_client(client)
246
+ rescue StandardError
247
+ nil
248
+ end
249
+ end
250
+ end
251
+
252
+ def retire_language_client(language, client)
253
+ retiring_language_clients[client] = true
254
+ invalidate_language_client_features(language, client)
255
+ (@retired_language_clients ||= ObjectSpace::WeakMap.new)[client] = true
256
+ @opened_lsp_documents&.keys&.each do |owner, buffer|
257
+ next unless owner.equal?(client)
258
+ close_language_document(client, buffer) if buffer.path
259
+ rescue Sadr::Error
260
+ forget_language_document(client, buffer)
261
+ end
262
+ stop_language_client(client)
263
+ @window&.request_frame
264
+ end
265
+
266
+ def retiring_language_clients
267
+ @retiring_language_clients ||= {}.compare_by_identity
268
+ end
269
+
270
+ def stop_language_client(client)
271
+ retiring_language_clients[client] = true
272
+ client.stop
273
+ retiring_language_clients.delete(client)
274
+ end
275
+
276
+ def invalidate_language_client_features(language, client, clear_diagnostics: false)
277
+ cancel_workspace_symbol_search
278
+ invalidate_document_highlights(client: client)
279
+ invalidate_document_links(client: client)
280
+ invalidate_folding_ranges(client: client)
281
+ invalidate_selection_ranges(client: client)
282
+ invalidate_hierarchy(client: client)
283
+ invalidate_prepare_rename(client: client)
284
+ invalidate_linked_editing_ranges(client: client)
285
+ invalidate_inlay_hints(client: client)
286
+ invalidate_code_lenses(client: client)
287
+ invalidate_sticky_symbols(client: client)
288
+ clear_language_client_diagnostics(client) if clear_diagnostics
289
+ @completion_generation = @completion_generation.to_i + 1
290
+ @semantic_styles&.delete_if { |buffer, _| definition_for(buffer.path).name == language }
291
+ self.palette = nil if %i[completion locations symbols code_actions workspace_symbol_results].include?(@palette&.dig(:kind)) ||
292
+ @palette&.dig(:client).equal?(client) || @palette&.dig(:item_clients)&.include?(client)
293
+ @hover_card = nil
294
+ @window&.request_frame
295
+ end
296
+
297
+ def start_language_client(language, options, index:, timeout: nil)
124
298
  raise Error, "Workspace closed" if @closed
125
- dispatcher = ->(&block) { @window ? (@main_queue ||= Queue.new) << block : block.call }
126
- replacement = LSP::Client.new(**options, root: @root, dispatch: dispatcher)
127
- replacement.on("error") { |error| @message = error.message if @clients[language].equal?(replacement) }
299
+ dispatcher = ->(&block) { @window ? @main_queue << block : block.call }
300
+ replacement = Sadr::Client.new(**options.except(:features, :legacy), root: @root, dispatch: dispatcher)
301
+ replacement.on("error") { |error| @message = error.message if language_client_active?(replacement) }
128
302
  replacement.on("workspace/configuration") do |params|
129
- configured = @client_options.fetch(language).fetch(:configuration)
303
+ current = language_server_option(language, replacement)
304
+ configured = (current || options).fetch(:configuration)
130
305
  values = @settings.for_language(language).values
131
306
  params.fetch("items").map do |item|
132
307
  section = item["section"]
@@ -136,45 +311,86 @@ module Canopus
136
311
  value.nil? ? {} : value
137
312
  end
138
313
  end
139
- replacement.on("workspace/applyEdit") do |params|
140
- future = LSP::Future.new(nil)
141
- if @closed || @retired_language_clients&.[](replacement)
142
- future.fulfill({"applied" => false, "failureReason" => "Language server stopped"})
143
- else
144
- confirm_workspace_edit(params.fetch("edit"), label: params.fetch("label", "Apply language server changes?"), response: future)
145
- @palette[:client] = replacement
146
- end
147
- future
148
- end
149
- replacement.on("textDocument/publishDiagnostics") { |_| @window&.request_frame }
150
- (@starting_language_clients ||= {})[language] = replacement
314
+ replacement.on("workspace/applyEdit") { |params| handle_language_workspace_edit(replacement, params) }
315
+ replacement.on("textDocument/publishDiagnostics") { |params| accept_diagnostic_notification(replacement, params) }
316
+ replacement.on("workspace/inlayHint/refresh") { invalidate_inlay_hints(client: replacement) }
317
+ replacement.on("workspace/codeLens/refresh") { invalidate_code_lenses(client: replacement) }
318
+ key = [language, index]
319
+ (@starting_language_clients ||= {})[key] = replacement
151
320
  begin
152
- replacement.start
321
+ timeout ? replacement.start(timeout: timeout) : replacement.start
153
322
  raise Error, "Workspace closed" if @closed
154
- @clients[language] = replacement
155
- @opened_lsp_documents ||= {}
156
- @buffers.values.uniq.each do |buffer|
157
- next unless buffer.path && !buffer.read_only && definition_for(buffer.path).name == language
158
- replacement.open_document(buffer, language_id: language)
159
- @opened_lsp_documents[[replacement, buffer]] = true
160
- end
161
323
  replacement
162
- rescue StandardError
163
- replacement.stop
164
- @clients.delete(language) if @clients[language].equal?(replacement)
165
- @opened_lsp_documents&.delete_if { |(owner, _), _| owner.equal?(replacement) }
166
- raise
324
+ rescue StandardError => error
325
+ begin
326
+ stop_language_client(replacement)
327
+ rescue StandardError
328
+ nil
329
+ end
330
+ @opened_lsp_documents&.keys&.each do |owner, buffer|
331
+ forget_language_document(owner, buffer) if owner.equal?(replacement)
332
+ end
333
+ raise error
167
334
  ensure
168
- @starting_language_clients.delete(language)
335
+ @starting_language_clients.delete(key)
336
+ end
337
+ end
338
+
339
+ def open_language_client_documents(language, client)
340
+ @opened_lsp_documents ||= {}
341
+ @buffers.values.uniq.each do |buffer|
342
+ next unless buffer.path && !buffer.read_only && definition_for(buffer.path).name == language
343
+ open_language_document(client, buffer, language) unless @opened_lsp_documents[[client, buffer]]
344
+ end
345
+ client
346
+ end
347
+
348
+ def handle_language_workspace_edit(client, params)
349
+ future = Sadr::Future.new(nil)
350
+ if @closed || @retired_language_clients&.[](client)
351
+ future.fulfill({"applied" => false, "failureReason" => "Language server stopped"})
352
+ elsif @save_action_clients&.key?(client)
353
+ begin
354
+ edit = params.fetch("edit")
355
+ raise Error, "save-time code actions cannot change workspace resources" if resource_workspace_edit?(edit)
356
+ result = apply_workspace_edit(edit)
357
+ (@save_action_errors ||= {})[client] = result["failureReason"].to_s unless result["applied"]
358
+ future.fulfill(result)
359
+ rescue StandardError => error
360
+ (@save_action_errors ||= {})[client] = error.message
361
+ future.fulfill({"applied" => false, "failureReason" => error.message})
362
+ end
363
+ else
364
+ confirm_workspace_edit(params.fetch("edit"), label: params.fetch("label", "Apply language server changes?"), response: future)
365
+ @palette[:client] = client
169
366
  end
367
+ future
170
368
  end
171
369
 
172
370
  def stop_language_servers
173
- @starting_language_clients&.values&.each(&:stop)
371
+ starting = @starting_language_clients&.values&.uniq || []
372
+ starting.each do |client|
373
+ stop_language_client(client)
374
+ rescue StandardError
375
+ nil
376
+ end
377
+ resyncs = @language_document_resyncs&.values || []
174
378
  (@client_lock ||= Mutex.new).synchronize do
175
- @clients.each_value(&:stop)
379
+ @language_document_subscriptions&.each_value(&:detach)
380
+ @language_document_subscriptions&.clear
381
+ @opened_lsp_documents&.clear
382
+ active = @language_clients ? @language_clients.values.flatten : @clients.values
383
+ clients = [*active, *retiring_language_clients.keys].compact.uniq
384
+ clients.each do |client|
385
+ stop_language_client(client)
386
+ rescue StandardError
387
+ nil
388
+ end
389
+ @language_clients&.clear
176
390
  @clients.clear
177
391
  end
392
+ resyncs.each { |thread| thread.join unless thread.equal?(Thread.current) }
393
+ @language_document_resyncs&.clear
178
394
  @language_reload_job&.join
179
395
  end
180
396
  end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "tempfile"
6
+
7
+ module Canopus
8
+ module Workspace::PersistentUndo
9
+ MAX_FILE_BYTES = 16 * 1024 * 1024
10
+ MAX_PROJECT_BYTES = 256 * 1024 * 1024
11
+ RECORD_NAME = /\A[0-9a-f]{64}\.json\z/
12
+
13
+ def persistent_undo_directory
14
+ File.join(@root, ".canopus", "undo")
15
+ end
16
+
17
+ def load_persistent_undo(buffer)
18
+ return false unless persistent_undo_enabled? && persistent_undo_eligible?(buffer)
19
+ path = persistent_undo_record_path(buffer.path)
20
+ return false unless path && File.file?(path) && !File.symlink?(path)
21
+ return false if File.size(path) > MAX_FILE_BYTES
22
+ record = JSON.parse(File.binread(path), max_nesting: 100)
23
+ return false unless persistent_undo_record_valid?(record, buffer, path)
24
+ buffer.restore_persistent_undo!(record)
25
+ rescue StandardError
26
+ false
27
+ end
28
+
29
+ def save_persistent_undo(buffer)
30
+ return false unless persistent_undo_enabled? && persistent_undo_eligible?(buffer)
31
+ record_path = persistent_undo_record_path(buffer.path)
32
+ return false unless record_path && persistent_undo_directory_ready?
33
+ record = buffer.persistent_undo_record(max_entries: @settings["persistent_undo"]["max_entries"])
34
+ unless record
35
+ persistent_undo_delete(record_path)
36
+ return true
37
+ end
38
+ stat = File.stat(buffer.path)
39
+ digest = buffer.disk_digest || Digest::SHA256.file(buffer.path).hexdigest
40
+ record.merge!("version" => 1, "project_root" => @root,
41
+ "relative_path" => persistent_undo_relative_path(buffer.path), "saved_at" => Time.now.to_i,
42
+ "file" => {"size" => stat.size, "digest" => digest, "dev" => stat.dev, "ino" => stat.ino})
43
+ payload = JSON.generate(record)
44
+ return false if stat.size > MAX_FILE_BYTES || payload.bytesize > MAX_FILE_BYTES
45
+ persistent_undo_make_room(record_path, payload.bytesize)
46
+ return false if persistent_undo_project_bytes(except: record_path) + payload.bytesize > MAX_PROJECT_BYTES
47
+ Tempfile.create([".undo-", ".json"], persistent_undo_directory, binmode: true) do |file|
48
+ file.chmod(0o600)
49
+ file.write(payload)
50
+ file.flush
51
+ file.fsync
52
+ file.close
53
+ File.rename(file.path, record_path)
54
+ end
55
+ true
56
+ rescue StandardError
57
+ false
58
+ end
59
+
60
+ private
61
+
62
+ def mark_persistent_undo_path(buffer, path)
63
+ expanded = File.expand_path(path, @root)
64
+ buffer.instance_variable_set(:@persistent_undo_ineligible, true) if File.symlink?(expanded)
65
+ rescue SystemCallError
66
+ buffer.instance_variable_set(:@persistent_undo_ineligible, true)
67
+ end
68
+
69
+ def persistent_undo_enabled?
70
+ persistent = @settings["persistent_undo"]
71
+ persistent.is_a?(Hash) && persistent["enabled"] == true
72
+ end
73
+
74
+ def persistent_undo_eligible?(buffer)
75
+ buffer.is_a?(Buffer) && !buffer.is_a?(MultiBuffer) && !buffer.instance_variable_get(:@persistent_undo_ineligible) && buffer.path && !buffer.read_only && !buffer.dirty? &&
76
+ persistent_undo_relative_path(buffer.path) && File.file?(buffer.path) && !File.symlink?(buffer.path)
77
+ end
78
+
79
+ def persistent_undo_relative_path(path)
80
+ absolute = File.expand_path(path)
81
+ return unless absolute.start_with?("#{@root}#{File::SEPARATOR}")
82
+ return unless File.realpath(absolute) == absolute
83
+ absolute.delete_prefix("#{@root}#{File::SEPARATOR}").then { |relative| relative unless relative.empty? || relative.start_with?("..#{File::SEPARATOR}") }
84
+ rescue SystemCallError
85
+ nil
86
+ end
87
+
88
+ def persistent_undo_record_path(path)
89
+ relative = persistent_undo_relative_path(path)
90
+ return unless relative
91
+ key = Digest::SHA256.hexdigest("#{@root}\0#{relative}")
92
+ File.join(persistent_undo_directory, "#{key}.json")
93
+ end
94
+
95
+ def persistent_undo_directory_ready?
96
+ base = File.join(@root, ".canopus")
97
+ return false if File.symlink?(base)
98
+ FileUtils.mkdir_p(base)
99
+ return false unless File.directory?(base) && !File.symlink?(base)
100
+ directory = persistent_undo_directory
101
+ return false if File.symlink?(directory)
102
+ FileUtils.mkdir_p(directory)
103
+ return false unless File.directory?(directory) && !File.symlink?(directory)
104
+ File.chmod(0o700, directory)
105
+ true
106
+ rescue SystemCallError
107
+ false
108
+ end
109
+
110
+ def persistent_undo_record_valid?(record, buffer, record_path)
111
+ return false unless record.is_a?(Hash) && record["version"] == 1 && record["project_root"] == @root
112
+ relative = persistent_undo_relative_path(buffer.path)
113
+ return false unless relative && record["relative_path"] == relative
114
+ return false unless File.basename(record_path) == File.basename(persistent_undo_record_path(buffer.path))
115
+ saved_at = record["saved_at"]
116
+ return false unless saved_at.is_a?(Integer) && saved_at >= 0
117
+ expire_days = @settings["persistent_undo"]["expire_days"]
118
+ return false if saved_at < Time.now.to_i - expire_days * 86_400
119
+ file = record["file"]
120
+ return false unless file.is_a?(Hash)
121
+ stat = File.lstat(buffer.path)
122
+ return false unless stat.file? && !File.symlink?(buffer.path)
123
+ return false unless file["size"] == stat.size && stat.size <= MAX_FILE_BYTES
124
+ return false unless file["dev"] == stat.dev && file["ino"] == stat.ino
125
+ digest = file["digest"]
126
+ return false unless digest.is_a?(String) && digest.match?(/\A[0-9a-f]{64}\z/)
127
+ return false unless digest == Digest::SHA256.file(buffer.path).hexdigest && digest == buffer.disk_digest
128
+ history = record["history"]
129
+ history.is_a?(Array) && history.length.between?(1, @settings["persistent_undo"]["max_entries"])
130
+ rescue StandardError
131
+ false
132
+ end
133
+
134
+ def persistent_undo_files
135
+ return [] unless persistent_undo_directory_ready?
136
+ Dir.children(persistent_undo_directory).filter_map do |name|
137
+ next unless name.match?(RECORD_NAME)
138
+ path = File.join(persistent_undo_directory, name)
139
+ next unless File.file?(path) && !File.symlink?(path) && File.size(path) <= MAX_FILE_BYTES
140
+ [path, File.mtime(path).to_i]
141
+ rescue SystemCallError
142
+ nil
143
+ end
144
+ rescue SystemCallError
145
+ []
146
+ end
147
+
148
+ def persistent_undo_project_bytes(except: nil)
149
+ persistent_undo_files.sum { |path, _| path == except ? 0 : File.size(path) }
150
+ rescue SystemCallError
151
+ MAX_PROJECT_BYTES
152
+ end
153
+
154
+ def persistent_undo_make_room(record_path, required)
155
+ persistent_undo_files.reject { |path, _| path == record_path }.sort_by(&:last).each do |path, _|
156
+ break if persistent_undo_project_bytes(except: record_path) + required <= MAX_PROJECT_BYTES
157
+ persistent_undo_delete(path)
158
+ end
159
+ end
160
+
161
+ def persistent_undo_delete(path)
162
+ return false unless File.file?(path) && !File.symlink?(path)
163
+ File.delete(path)
164
+ true
165
+ rescue SystemCallError
166
+ false
167
+ end
168
+ end
169
+ end