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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +55 -0
- data/README.md +191 -19
- data/assets/keymaps/emacs.jsonc +5 -0
- data/assets/keymaps/jetbrains.jsonc +5 -0
- data/assets/keymaps/sublime.jsonc +5 -0
- data/assets/keymaps/vscode.jsonc +5 -0
- data/docs/debugging.md +143 -0
- data/docs/distribution.md +40 -9
- data/docs/lsp.md +131 -0
- data/docs/performance.md +18 -0
- data/docs/providers.md +36 -0
- data/docs/release-log.md +12 -0
- data/docs/tasks.md +64 -0
- data/docs/testing.md +49 -0
- data/docs/vim.md +3 -0
- data/exe/canopus +4 -1
- data/lib/canopus/buffer.rb +291 -50
- data/lib/canopus/cli.rb +5 -2
- data/lib/canopus/command.rb +13 -4
- data/lib/canopus/controller.rb +183 -40
- data/lib/canopus/debug/breakpoints.rb +550 -0
- data/lib/canopus/debug/configuration.rb +291 -0
- data/lib/canopus/debug/console.rb +243 -0
- data/lib/canopus/debug/panel.rb +487 -0
- data/lib/canopus/debug/session.rb +354 -0
- data/lib/canopus/decoration.rb +4 -2
- data/lib/canopus/denebola_compat.rb +27 -0
- data/lib/canopus/diagnostics.rb +173 -0
- data/lib/canopus/display_map/overlay_map.rb +6 -2
- data/lib/canopus/editor/snippet_expandable.rb +3 -2
- data/lib/canopus/editor.rb +122 -31
- data/lib/canopus/git/state.rb +100 -0
- data/lib/canopus/language/background_analysis.rb +93 -14
- data/lib/canopus/language/document.rb +70 -34
- data/lib/canopus/language/symbol.rb +1 -0
- data/lib/canopus/language/syntax_worker.rb +61 -9
- data/lib/canopus/language.rb +3 -0
- data/lib/canopus/minimap.rb +201 -0
- data/lib/canopus/patch.rb +3 -1
- data/lib/canopus/plugins/api.rb +4 -2
- data/lib/canopus/plugins/isolated_runtime.rb +64 -7
- data/lib/canopus/plugins/registry.rb +114 -3
- data/lib/canopus/plugins.rb +8 -7
- data/lib/canopus/project.rb +15 -6
- data/lib/canopus/provider.rb +192 -0
- data/lib/canopus/regexp_compat.rb +36 -4
- data/lib/canopus/selection.rb +12 -0
- data/lib/canopus/settings/schema.rb +201 -0
- data/lib/canopus/settings.rb +328 -49
- data/lib/canopus/task/configuration.rb +356 -0
- data/lib/canopus/task/problem_matcher.rb +264 -0
- data/lib/canopus/task/runner.rb +314 -0
- data/lib/canopus/test_runner/bounded_ignore.rb +144 -0
- data/lib/canopus/test_runner/discovery.rb +97 -0
- data/lib/canopus/test_runner/execution.rb +194 -0
- data/lib/canopus/test_runner/minitest.rb +116 -0
- data/lib/canopus/test_runner/rspec.rb +103 -0
- data/lib/canopus/test_runner/safe_walker.rb +37 -0
- data/lib/canopus/test_runner.rb +54 -0
- data/lib/canopus/theme/import.rb +131 -0
- data/lib/canopus/theme.rb +28 -4
- data/lib/canopus/version.rb +1 -1
- data/lib/canopus/vim/motionable.rb +1 -1
- data/lib/canopus/vim.rb +10 -16
- data/lib/canopus/workspace/auto_savable.rb +83 -0
- data/lib/canopus/workspace/debug_aware.rb +600 -0
- data/lib/canopus/workspace/edit/plan.rb +2 -2
- data/lib/canopus/workspace/editor_configurable.rb +113 -0
- data/lib/canopus/workspace/file_change_aware.rb +11 -3
- data/lib/canopus/workspace/git_aware.rb +80 -34
- data/lib/canopus/workspace/git_blame.rb +175 -0
- data/lib/canopus/workspace/git_conflict_resolution.rb +410 -0
- data/lib/canopus/workspace/git_diff_view.rb +641 -0
- data/lib/canopus/workspace/git_history.rb +171 -0
- data/lib/canopus/workspace/git_remote.rb +267 -0
- data/lib/canopus/workspace/git_staging.rb +583 -0
- data/lib/canopus/workspace/hierarchy_aware.rb +477 -0
- data/lib/canopus/workspace/keymap_aware.rb +66 -0
- data/lib/canopus/workspace/language_aware.rb +3721 -135
- data/lib/canopus/workspace/language_server_configurable.rb +287 -71
- data/lib/canopus/workspace/persistent_undo.rb +169 -0
- data/lib/canopus/workspace/problems_aware.rb +131 -0
- data/lib/canopus/workspace/project_searchable.rb +88 -11
- data/lib/canopus/workspace/project_tree_editable.rb +3 -1
- data/lib/canopus/workspace/recoverable.rb +204 -0
- data/lib/canopus/workspace/session_persistable.rb +85 -13
- data/lib/canopus/workspace/settings_aware.rb +76 -2
- data/lib/canopus/workspace/task_aware.rb +194 -0
- data/lib/canopus/workspace/test_aware.rb +394 -0
- data/lib/canopus/workspace/trust.rb +73 -0
- data/lib/canopus/workspace/view/terminal_presentable.rb +298 -47
- data/lib/canopus/workspace/view.rb +399 -74
- data/lib/canopus/workspace.rb +688 -44
- data/lib/canopus.rb +20 -2
- data/sig/canopus.rbs +138 -31
- data/sig/controller.rbs +10 -3
- data/sig/debug.rbs +121 -0
- data/sig/decoration.rbs +12 -2
- data/sig/diagnostics.rbs +26 -0
- data/sig/minimap.rbs +20 -0
- data/sig/provider.rbs +29 -0
- data/sig/services.rbs +1 -16
- data/sig/task.rbs +92 -0
- data/sig/test_runner.rbs +109 -0
- data/sig/trust.rbs +17 -0
- data/sig/workspace_edits.rbs +2 -2
- data/sig/workspace_services.rbs +234 -2
- data/tools/check_dependencies.rb +21 -2
- data/tools/package.rb +172 -14
- data/tools/package_signature_test.rb +33 -0
- data/tools/package_test.rb +29 -0
- data/tools/sign_package.rb +123 -0
- data/tools/update.rb +189 -0
- data/tools/update_test.rb +71 -0
- metadata +206 -31
- data/lib/canopus/lsp/client.rb +0 -307
- data/lib/canopus/lsp/error.rb +0 -7
- data/lib/canopus/lsp/future/subscription.rb +0 -9
- data/lib/canopus/lsp/future.rb +0 -87
- data/lib/canopus/lsp/protocol.rb +0 -83
- data/lib/canopus/lsp/server_error.rb +0 -13
- data/lib/canopus/lsp/timeout.rb +0 -7
- data/lib/canopus/lsp/transport.rb +0 -123
- data/lib/canopus/lsp.rb +0 -19
- data/lib/canopus/project/search.rb +0 -164
- data/lib/canopus/project/search_worker/cancelled.rb +0 -3
- data/lib/canopus/project/search_worker/runner.rb +0 -9
- data/lib/canopus/project/search_worker.rb +0 -108
- data/lib/canopus/terminal/cell.rb +0 -7
- data/lib/canopus/terminal/grid.rb +0 -321
- data/lib/canopus/terminal/pty.rb +0 -178
- data/lib/canopus/terminal/scrollback.rb +0 -38
- data/lib/canopus/terminal/vt.rb +0 -398
- data/lib/canopus/terminal.rb +0 -13
- data/sig/lsp.rbs +0 -99
- 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
|
-
|
|
9
|
-
|
|
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
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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 ?
|
|
126
|
-
replacement =
|
|
127
|
-
replacement.on("error") { |error| @message = error.message if
|
|
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
|
-
|
|
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")
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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(
|
|
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&.
|
|
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
|
-
@
|
|
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
|