canopus 0.1.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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +148 -0
- data/docs/adr/001-runtime-gem-components.md +24 -0
- data/docs/adr/002-bounded-diff-engine.md +22 -0
- data/docs/adr/003-background-language-analysis.md +22 -0
- data/docs/adr/004-persistent-display-map.md +23 -0
- data/docs/adr/005-windows-runtime.md +27 -0
- data/docs/adr/README.md +36 -0
- data/docs/distribution.md +27 -0
- data/docs/lsp.md +38 -0
- data/docs/performance.md +40 -0
- data/docs/snippets.md +32 -0
- data/docs/vim.md +24 -0
- data/docs/workspace_edits.md +32 -0
- data/examples/native_smoke.rb +25 -0
- data/examples/plugins/word_count.rb +11 -0
- data/exe/canopus +19 -0
- data/lib/canopus/block_map.rb +19 -0
- data/lib/canopus/buffer.rb +293 -0
- data/lib/canopus/cli.rb +171 -0
- data/lib/canopus/controller.rb +507 -0
- data/lib/canopus/data_compat.rb +25 -0
- data/lib/canopus/display_map/line_builder.rb +54 -0
- data/lib/canopus/display_map/line_set.rb +8 -0
- data/lib/canopus/display_map/pending_line_set.rb +9 -0
- data/lib/canopus/display_map/summary.rb +23 -0
- data/lib/canopus/display_map/worker.rb +89 -0
- data/lib/canopus/display_map.rb +350 -0
- data/lib/canopus/display_point.rb +5 -0
- data/lib/canopus/editor/snippet_expandable.rb +281 -0
- data/lib/canopus/editor.rb +445 -0
- data/lib/canopus/error.rb +5 -0
- data/lib/canopus/fold_map.rb +87 -0
- data/lib/canopus/git/blame.rb +50 -0
- data/lib/canopus/git/commit.rb +7 -0
- data/lib/canopus/git/corrupt_object.rb +7 -0
- data/lib/canopus/git/diff.rb +131 -0
- data/lib/canopus/git/index.rb +94 -0
- data/lib/canopus/git/object_database.rb +45 -0
- data/lib/canopus/git/pack.rb +186 -0
- data/lib/canopus/git/repository.rb +313 -0
- data/lib/canopus/git/status.rb +74 -0
- data/lib/canopus/git/tree_entry.rb +7 -0
- data/lib/canopus/git.rb +15 -0
- data/lib/canopus/icon_theme.rb +43 -0
- data/lib/canopus/language/background_analysis/job.rb +12 -0
- data/lib/canopus/language/background_analysis/scheduler.rb +70 -0
- data/lib/canopus/language/background_analysis.rb +280 -0
- data/lib/canopus/language/definition.rb +7 -0
- data/lib/canopus/language/document.rb +143 -0
- data/lib/canopus/language/symbol.rb +7 -0
- data/lib/canopus/language/syntax_worker.rb +91 -0
- data/lib/canopus/language.rb +42 -0
- data/lib/canopus/lazy_rope.rb +218 -0
- data/lib/canopus/lsp/client.rb +299 -0
- data/lib/canopus/lsp/error.rb +7 -0
- data/lib/canopus/lsp/future/subscription.rb +9 -0
- data/lib/canopus/lsp/future.rb +87 -0
- data/lib/canopus/lsp/protocol.rb +83 -0
- data/lib/canopus/lsp/server_error.rb +13 -0
- data/lib/canopus/lsp/timeout.rb +7 -0
- data/lib/canopus/lsp/transport.rb +123 -0
- data/lib/canopus/lsp.rb +19 -0
- data/lib/canopus/markdown.rb +69 -0
- data/lib/canopus/match_data_compat.rb +17 -0
- data/lib/canopus/multi_buffer.rb +266 -0
- data/lib/canopus/pane.rb +61 -0
- data/lib/canopus/patch/composite.rb +12 -0
- data/lib/canopus/patch/reload.rb +18 -0
- data/lib/canopus/patch.rb +27 -0
- data/lib/canopus/performance_recorder.rb +207 -0
- data/lib/canopus/plugins/api.rb +50 -0
- data/lib/canopus/plugins/isolated_runtime.rb +167 -0
- data/lib/canopus/plugins/local_runtime.rb +25 -0
- data/lib/canopus/plugins/permission_denied.rb +7 -0
- data/lib/canopus/plugins/registry.rb +30 -0
- data/lib/canopus/plugins.rb +11 -0
- data/lib/canopus/project/ignore_matcher.rb +104 -0
- data/lib/canopus/project/search.rb +164 -0
- data/lib/canopus/project/search_worker/cancelled.rb +3 -0
- data/lib/canopus/project/search_worker/runner.rb +9 -0
- data/lib/canopus/project/search_worker.rb +108 -0
- data/lib/canopus/project/tree.rb +48 -0
- data/lib/canopus/project/watcher.rb +59 -0
- data/lib/canopus/project.rb +127 -0
- data/lib/canopus/regexp_compat.rb +30 -0
- data/lib/canopus/save_conflict.rb +5 -0
- data/lib/canopus/selection.rb +11 -0
- data/lib/canopus/settings.rb +118 -0
- data/lib/canopus/snippet/transform.rb +209 -0
- data/lib/canopus/snippet.rb +183 -0
- data/lib/canopus/tab_map.rb +30 -0
- data/lib/canopus/terminal/cell.rb +7 -0
- data/lib/canopus/terminal/grid.rb +321 -0
- data/lib/canopus/terminal/pty.rb +103 -0
- data/lib/canopus/terminal/scrollback.rb +38 -0
- data/lib/canopus/terminal/vt.rb +398 -0
- data/lib/canopus/terminal.rb +13 -0
- data/lib/canopus/theme.rb +43 -0
- data/lib/canopus/version.rb +5 -0
- data/lib/canopus/vim/commandable.rb +148 -0
- data/lib/canopus/vim/motionable.rb +321 -0
- data/lib/canopus/vim/operator_capable.rb +377 -0
- data/lib/canopus/vim/text_object_selectable.rb +141 -0
- data/lib/canopus/vim.rb +426 -0
- data/lib/canopus/workspace/edit/executable.rb +139 -0
- data/lib/canopus/workspace/edit/node.rb +8 -0
- data/lib/canopus/workspace/edit/plan.rb +170 -0
- data/lib/canopus/workspace/edit/resource_preparable.rb +98 -0
- data/lib/canopus/workspace/edit.rb +6 -0
- data/lib/canopus/workspace/file_change_aware.rb +40 -0
- data/lib/canopus/workspace/file_previewable.rb +31 -0
- data/lib/canopus/workspace/git_aware.rb +140 -0
- data/lib/canopus/workspace/language_aware.rb +413 -0
- data/lib/canopus/workspace/language_server_configurable.rb +181 -0
- data/lib/canopus/workspace/project_searchable.rb +208 -0
- data/lib/canopus/workspace/project_tree_editable.rb +82 -0
- data/lib/canopus/workspace/session_persistable.rb +153 -0
- data/lib/canopus/workspace/settings_aware.rb +91 -0
- data/lib/canopus/workspace/view/terminal_presentable.rb +185 -0
- data/lib/canopus/workspace/view.rb +643 -0
- data/lib/canopus/workspace.rb +525 -0
- data/lib/canopus/wrap_map.rb +108 -0
- data/lib/canopus.rb +24 -0
- data/sig/canopus.rbs +375 -0
- data/sig/controller.rbs +55 -0
- data/sig/display_stages.rbs +49 -0
- data/sig/git.rbs +140 -0
- data/sig/lsp.rbs +99 -0
- data/sig/markdown.rbs +11 -0
- data/sig/performance_recorder.rbs +23 -0
- data/sig/search_services.rbs +8 -0
- data/sig/services.rbs +81 -0
- data/sig/snippet.rbs +42 -0
- data/sig/terminal.rbs +125 -0
- data/sig/vim.rbs +27 -0
- data/sig/workspace_edits.rbs +11 -0
- data/sig/workspace_services.rbs +117 -0
- data/tools/certify_snippet_regex.rb +35 -0
- data/tools/check_dependencies.rb +80 -0
- data/tools/native_check.rb +70 -0
- data/tools/package.rb +113 -0
- data/tools/package_test.rb +32 -0
- metadata +314 -0
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Canopus
|
|
4
|
+
module Workspace::LanguageAware
|
|
5
|
+
attr_reader :hover_card, :hover_markup, :semantic_styles
|
|
6
|
+
def dismiss_hover = @hover_card = nil
|
|
7
|
+
def close_language_documents(buffer = nil)
|
|
8
|
+
@opened_lsp_documents&.keys&.each do |key|
|
|
9
|
+
client, document = key
|
|
10
|
+
next if buffer && !document.equal?(buffer)
|
|
11
|
+
@opened_lsp_documents.delete(key)
|
|
12
|
+
begin
|
|
13
|
+
client.close_document(LSP::Protocol.uri(document.path)) if document.path
|
|
14
|
+
rescue StandardError => error
|
|
15
|
+
self.message = "Language server close failed: #{error.message}"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
def semantic_spans_for(buffer, row)
|
|
20
|
+
saved = @semantic_styles && @semantic_styles[buffer]
|
|
21
|
+
return [] unless saved && saved.first == buffer.version
|
|
22
|
+
base = buffer.rope.line_start(row)
|
|
23
|
+
map = {"class" => "Name.Class", "type" => "Name.Class", "function" => "Name.Function", "method" => "Name.Function",
|
|
24
|
+
"keyword" => "Keyword", "comment" => "Comment", "string" => "Literal.String", "number" => "Literal.Number"}
|
|
25
|
+
saved.last.filter_map do |token|
|
|
26
|
+
next unless token[:line] == row
|
|
27
|
+
first = buffer.rope.offset_at_utf16_point(Denebola::Point.new(row, token[:character]))
|
|
28
|
+
last = buffer.rope.offset_at_utf16_point(Denebola::Point.new(row, token[:character] + token[:length]))
|
|
29
|
+
[first - base, last - base, @theme.token_color(map.fetch(token[:name], "Name"))]
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def post(&block)
|
|
34
|
+
(@main_queue ||= Queue.new) << block
|
|
35
|
+
@window&.request_frame
|
|
36
|
+
end
|
|
37
|
+
def language_client(buffer = editor.buffer)
|
|
38
|
+
raise Error, "language servers are disabled for large read-only documents" if buffer.read_only
|
|
39
|
+
language = definition_for(buffer.path)
|
|
40
|
+
(@client_lock ||= Mutex.new).synchronize do
|
|
41
|
+
options = language_server_options(language.name)
|
|
42
|
+
raise Error, "No language server configured for #{language.name}" unless options
|
|
43
|
+
client = ensure_language_server(language.name, options)
|
|
44
|
+
@opened_lsp_documents ||= {}
|
|
45
|
+
unless @opened_lsp_documents[[client, buffer]]
|
|
46
|
+
client.open_document(buffer, language_id: language.name)
|
|
47
|
+
@opened_lsp_documents[[client, buffer]] = true
|
|
48
|
+
end
|
|
49
|
+
client
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
def language_request(kind, **options)
|
|
53
|
+
current, buffer, offset = editor, editor.buffer, editor.primary.head
|
|
54
|
+
version = buffer.version
|
|
55
|
+
(@language_jobs ||= []) << Thread.new do
|
|
56
|
+
begin
|
|
57
|
+
client = language_client(buffer)
|
|
58
|
+
result = case kind
|
|
59
|
+
when :completion, :hover, :definition, :typeDefinition, :implementation, :signatureHelp
|
|
60
|
+
client.public_send(kind, buffer, offset).await
|
|
61
|
+
when :references then client.references(buffer, offset, context: {includeDeclaration: true}).await
|
|
62
|
+
when :rename then client.rename(buffer, offset, newName: options.fetch(:name)).await
|
|
63
|
+
when :formatting then client.formatting(buffer, options: {tabSize: current.tab_size, insertSpaces: !current.use_tabs}).await
|
|
64
|
+
when :codeAction
|
|
65
|
+
client.codeAction(buffer, range: LSP::Protocol.range(buffer.rope, current.primary.range), context: {diagnostics: diagnostics_for(buffer)}).await
|
|
66
|
+
when :documentSymbol, :codeLens, :diagnostic then client.public_send(kind, buffer).await
|
|
67
|
+
when :inlayHint then client.inlayHint(buffer, range: LSP::Protocol.range(buffer.rope, 0...buffer.rope.bytesize)).await
|
|
68
|
+
when :semantic_tokens then client.semantic_tokens(buffer)
|
|
69
|
+
when :workspace_symbols then client.workspace_symbols(options.fetch(:query, "")).await
|
|
70
|
+
else raise Error, "unknown language request #{kind}"
|
|
71
|
+
end
|
|
72
|
+
post do
|
|
73
|
+
next unless @clients.value?(client)
|
|
74
|
+
next unless @panes.any? { |pane| pane.editors.include?(current) }
|
|
75
|
+
if buffer.version == version
|
|
76
|
+
display_language_result(kind, result, client, current)
|
|
77
|
+
else
|
|
78
|
+
@message = "Document changed; request #{kind} again"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
rescue StandardError => error
|
|
82
|
+
post { @message = error.message unless client && @retired_language_clients&.[](client) }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
@language_jobs.reject! { |thread| !thread.alive? }
|
|
86
|
+
@message = "#{kind}…"
|
|
87
|
+
end
|
|
88
|
+
def diagnostics_for(buffer)
|
|
89
|
+
return [] if !buffer.path || @clients.empty?
|
|
90
|
+
uri = LSP::Protocol.uri(buffer.path)
|
|
91
|
+
@clients.values.flat_map { |client| client.diagnostics.fetch(uri, []) }
|
|
92
|
+
end
|
|
93
|
+
def resource_workspace_edit?(edit)
|
|
94
|
+
edit.is_a?(Hash) && edit["documentChanges"].is_a?(Array) && edit["documentChanges"].any? { |change| change.is_a?(Hash) && change["kind"] }
|
|
95
|
+
end
|
|
96
|
+
def confirm_workspace_edit(edit, label: "Apply language server changes?", response: nil, on_applied: nil)
|
|
97
|
+
raise Error, "invalid workspace edit" unless edit.is_a?(Hash)
|
|
98
|
+
changes = edit.fetch("documentChanges", [])
|
|
99
|
+
raise Error, "invalid documentChanges" unless changes.is_a?(Array) && changes.length <= 10_000
|
|
100
|
+
details = Array(edit["documentChanges"]).filter_map do |change|
|
|
101
|
+
next unless change.is_a?(Hash) && change["kind"]
|
|
102
|
+
paths = change["kind"] == "rename" ? change.values_at("oldUri", "newUri") : [change["uri"]]
|
|
103
|
+
raise Error, "resource URI is too long" unless paths.all? { |uri| uri.is_a?(String) && uri.bytesize <= 16_384 }
|
|
104
|
+
suffix = change.dig("options", "overwrite") ? " (overwrite; backup retained)" : ""
|
|
105
|
+
"#{change['kind']}: #{paths.map { |uri| LSP::Protocol.path(uri) }.join(' → ')}#{suffix}"
|
|
106
|
+
end
|
|
107
|
+
self.palette = {kind: :workspace_edit, query: label.to_s.slice(0, 1_024), index: details.empty? ? 0 : 1, matches: ["Apply changes", "Cancel"],
|
|
108
|
+
edit: edit, response: response, on_applied: on_applied, details: details}
|
|
109
|
+
@window&.request_frame
|
|
110
|
+
@palette
|
|
111
|
+
end
|
|
112
|
+
def apply_workspace_edit(edit, confirmed: false)
|
|
113
|
+
raise Error, "invalid workspace edit" unless edit.is_a?(Hash)
|
|
114
|
+
if resource_workspace_edit?(edit)
|
|
115
|
+
raise Error, "Resource changes require a file-operation confirmation" unless confirmed
|
|
116
|
+
require_relative "edit/plan"
|
|
117
|
+
return Workspace::Edit::Plan.new(self, edit).apply
|
|
118
|
+
end
|
|
119
|
+
# LSP prefers documentChanges when both representations are supplied.
|
|
120
|
+
documents = if edit.key?("documentChanges")
|
|
121
|
+
changes = edit["documentChanges"]
|
|
122
|
+
raise Error, "invalid documentChanges" unless changes.is_a?(Array)
|
|
123
|
+
changes.map do |change|
|
|
124
|
+
raise Error, "invalid document change" unless change.is_a?(Hash)
|
|
125
|
+
raise Error, "Resource changes require a file-operation confirmation" if change["kind"]
|
|
126
|
+
[change.fetch("textDocument"), change.fetch("edits")]
|
|
127
|
+
end
|
|
128
|
+
else
|
|
129
|
+
changes = edit.fetch("changes", {})
|
|
130
|
+
raise Error, "invalid workspace changes" unless changes.is_a?(Hash)
|
|
131
|
+
changes.map { |uri, edits| [{"uri" => uri}, edits] }
|
|
132
|
+
end
|
|
133
|
+
pending, snapshots, originals = {}, {}, {}
|
|
134
|
+
plans = documents.map do |document, edits|
|
|
135
|
+
raise Error, "invalid text document edit" unless document.is_a?(Hash) && edits.is_a?(Array)
|
|
136
|
+
path = canonical_path(LSP::Protocol.path(document.fetch("uri")))
|
|
137
|
+
raise Error, "language server edit is outside project" unless path.start_with?(@root + File::SEPARATOR)
|
|
138
|
+
actual = File.exist?(path) ? File.realpath(path) : path
|
|
139
|
+
raise Error, "language server edit follows a link outside project" unless actual.start_with?(@root + File::SEPARATOR)
|
|
140
|
+
buffer = @buffers[path] || (pending[path] ||= Buffer.open(path))
|
|
141
|
+
raise Error, "cannot edit a read-only document" if buffer.read_only
|
|
142
|
+
originals[buffer] ||= [buffer.rope, buffer.version]
|
|
143
|
+
rope, version = snapshots.fetch(buffer) { originals.fetch(buffer) }
|
|
144
|
+
requested_version = document["version"]
|
|
145
|
+
unless requested_version.nil? || (requested_version.is_a?(Integer) && requested_version == version)
|
|
146
|
+
raise Error, "language server edit has stale document version"
|
|
147
|
+
end
|
|
148
|
+
changes = edits.map do |entry|
|
|
149
|
+
unless entry.is_a?(Hash) && entry["range"].is_a?(Hash) && entry["newText"].is_a?(String) && entry["newText"].valid_encoding?
|
|
150
|
+
raise Error, "invalid LSP text edit"
|
|
151
|
+
end
|
|
152
|
+
range = entry.fetch("range")
|
|
153
|
+
[LSP::Protocol.offset(rope, range.fetch("start"))...LSP::Protocol.offset(rope, range.fetch("end")), entry.fetch("newText")]
|
|
154
|
+
end
|
|
155
|
+
# Repeated TextDocumentEdits address the preceding staged snapshot.
|
|
156
|
+
snapshots[buffer] = [rope.apply_edits(changes), version + (changes.empty? ? 0 : 1)]
|
|
157
|
+
[buffer, changes]
|
|
158
|
+
end
|
|
159
|
+
unless originals.all? { |buffer, (rope, version)| buffer.rope.equal?(rope) && buffer.version == version }
|
|
160
|
+
raise Error, "document changed while preparing language server edits"
|
|
161
|
+
end
|
|
162
|
+
@buffers.merge!(pending)
|
|
163
|
+
grouped = []
|
|
164
|
+
originals.each_key { |buffer| buffer.begin_undo_group; grouped << buffer }
|
|
165
|
+
plans.each { |buffer, changes| buffer.edit(changes, kind: :lsp) }
|
|
166
|
+
{"applied" => true}
|
|
167
|
+
ensure
|
|
168
|
+
grouped&.reverse_each(&:end_undo_group)
|
|
169
|
+
pending&.each { |path, buffer| buffer.close unless @buffers[path].equal?(buffer) }
|
|
170
|
+
end
|
|
171
|
+
def accept_language_result(palette, index)
|
|
172
|
+
item = palette[:items][index]
|
|
173
|
+
return unless item
|
|
174
|
+
current = palette[:editor]
|
|
175
|
+
raise Error, "Document was closed; request again" if current && !@panes.any? { |pane| pane.editors.include?(current) }
|
|
176
|
+
if current && palette[:version] && current.buffer.version != palette[:version]
|
|
177
|
+
raise Error, "Document changed; request #{palette[:kind]} again"
|
|
178
|
+
end
|
|
179
|
+
client = palette[:client]
|
|
180
|
+
raise Error, "Language server settings changed; request again" if client && @retired_language_clients&.[](client)
|
|
181
|
+
provider, resolver = case palette[:kind]
|
|
182
|
+
when :completion then ["completionProvider", :resolve_completion]
|
|
183
|
+
when :code_actions then palette[:lens] ? ["codeLensProvider", :resolve_code_lens] : ["codeActionProvider", :resolve_code_action]
|
|
184
|
+
end
|
|
185
|
+
capability = client&.capabilities&.fetch(provider, nil) if provider && client.respond_to?(:capabilities)
|
|
186
|
+
if !palette[:resolved] && capability.is_a?(Hash) && capability["resolveProvider"]
|
|
187
|
+
(@language_jobs ||= []) << Thread.new do
|
|
188
|
+
resolved = client.public_send(resolver, item).await
|
|
189
|
+
post do
|
|
190
|
+
items = palette[:items].dup
|
|
191
|
+
items[index] = item.merge(resolved || {})
|
|
192
|
+
accept_language_result(palette.merge(items: items, resolved: true), index)
|
|
193
|
+
end
|
|
194
|
+
rescue StandardError => error
|
|
195
|
+
post { @message = error.message }
|
|
196
|
+
end
|
|
197
|
+
return
|
|
198
|
+
end
|
|
199
|
+
case palette[:kind]
|
|
200
|
+
when :completion
|
|
201
|
+
editor = palette[:editor]
|
|
202
|
+
text_edit = item["textEdit"]
|
|
203
|
+
range = if text_edit
|
|
204
|
+
range = text_edit["range"] || text_edit["replace"] || text_edit.fetch("insert")
|
|
205
|
+
LSP::Protocol.offset(editor.buffer.rope, range.fetch("start"))...LSP::Protocol.offset(editor.buffer.rope, range.fetch("end"))
|
|
206
|
+
else
|
|
207
|
+
editor.primary.range
|
|
208
|
+
end
|
|
209
|
+
value = text_edit ? text_edit.fetch("newText") : item["insertText"] || item.fetch("label")
|
|
210
|
+
snippet = item["insertTextFormat"] == 2
|
|
211
|
+
variables = editor.snippet_variables(workspace_root: @root, clipboard: @window.respond_to?(:clipboard) ? @window.clipboard : nil) if snippet
|
|
212
|
+
expanded = snippet ? Snippet.new(value, variables: variables).text : value
|
|
213
|
+
additional = item.fetch("additionalTextEdits", []).map do |entry|
|
|
214
|
+
[LSP::Protocol.offset(editor.buffer.rope, entry.fetch("range").fetch("start"))...LSP::Protocol.offset(editor.buffer.rope, entry.fetch("range").fetch("end")), entry.fetch("newText")]
|
|
215
|
+
end.sort_by { |entry| entry.first.begin }
|
|
216
|
+
editor.buffer.rope.apply_edits((additional + [[range, expanded]]).sort_by { |entry| entry.first.begin })
|
|
217
|
+
editor.buffer.begin_undo_group
|
|
218
|
+
begin
|
|
219
|
+
editor.select(range.begin, range.end)
|
|
220
|
+
editor.buffer.edit(additional, kind: :completion) unless additional.empty?
|
|
221
|
+
snippet ? editor.insert_snippet(value, variables: variables) : editor.insert_text(value, auto_indent: false)
|
|
222
|
+
ensure
|
|
223
|
+
editor.buffer.end_undo_group
|
|
224
|
+
end
|
|
225
|
+
show_snippet_choices(editor) if snippet
|
|
226
|
+
command = item["command"]
|
|
227
|
+
client.execute_command(command.fetch("command"), arguments: command.fetch("arguments", [])) if command && client
|
|
228
|
+
when :locations, :symbols
|
|
229
|
+
location = item["location"] || item
|
|
230
|
+
path = LSP::Protocol.path(location["uri"] || location.fetch("targetUri"))
|
|
231
|
+
opened = open(path)
|
|
232
|
+
range = location["range"] || location.fetch("targetSelectionRange")
|
|
233
|
+
opened.select(LSP::Protocol.offset(opened.buffer.rope, range.fetch("start")))
|
|
234
|
+
opened.reveal_cursor
|
|
235
|
+
when :code_actions
|
|
236
|
+
raise Error, item["disabled"]["reason"].to_s if item["disabled"]
|
|
237
|
+
run_command = lambda do
|
|
238
|
+
command = item["command"]
|
|
239
|
+
if command
|
|
240
|
+
command = item if command.is_a?(String)
|
|
241
|
+
palette[:client].execute_command(command.fetch("command"), arguments: command.fetch("arguments", []))
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
return confirm_workspace_edit(item["edit"], label: item.fetch("title", "Apply code action?"), on_applied: run_command) if resource_workspace_edit?(item["edit"])
|
|
245
|
+
apply_workspace_edit(item["edit"]) if item["edit"]
|
|
246
|
+
run_command.call
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
def show_diagnostics
|
|
250
|
+
items, labels = [], []
|
|
251
|
+
@buffers.each_value.uniq.each do |buffer|
|
|
252
|
+
next unless buffer.path
|
|
253
|
+
diagnostics_for(buffer).each do |diagnostic|
|
|
254
|
+
row = diagnostic.dig("range", "start", "line")
|
|
255
|
+
next unless row.is_a?(Integer)
|
|
256
|
+
labels << "#{File.basename(buffer.path)}:#{row + 1} #{diagnostic['message']}"
|
|
257
|
+
items << {"uri" => LSP::Protocol.uri(buffer.path), "range" => diagnostic.fetch("range")}
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
self.palette = {kind: :locations, query: +"", index: 0, matches: labels, items: items}
|
|
261
|
+
update_palette
|
|
262
|
+
end
|
|
263
|
+
def show_outline
|
|
264
|
+
if editor.language_document.definition.name == "ruby"
|
|
265
|
+
document = editor.language_document
|
|
266
|
+
symbols = document.outline
|
|
267
|
+
self.palette = {kind: :outline, query: +"", index: 0, matches: symbols.map { |symbol| "#{' ' * symbol.depth}#{symbol.name}" }, items: symbols,
|
|
268
|
+
editor: editor, document: document, version: editor.buffer.version, loading: !document.syntax_ready?, partial: !document.syntax_complete?}
|
|
269
|
+
@message = @palette[:loading] ? "Analyzing outline…" : @palette[:partial] ? "Outline (visible region only)" : "Outline"
|
|
270
|
+
else
|
|
271
|
+
language_request(:documentSymbol)
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
def fold_current
|
|
275
|
+
current, document = editor, editor.language_document
|
|
276
|
+
ranges = document.fold_ranges
|
|
277
|
+
if document.syntax_ready?
|
|
278
|
+
range = ranges.reverse.find { |item| item.cover?(current.primary.head) }
|
|
279
|
+
current.display_map.fold(range) if range
|
|
280
|
+
else
|
|
281
|
+
@pending_fold = [current, document, current.buffer.version, current.primary.head]
|
|
282
|
+
@message = "Analyzing fold ranges…"
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
def language_ready(current, document)
|
|
286
|
+
if @palette&.dig(:kind) == :outline && @palette[:editor].equal?(current) && @palette[:document].equal?(document)
|
|
287
|
+
if @palette[:version] != current.buffer.version || (!@palette[:loading] && !document.syntax_ready?)
|
|
288
|
+
@palette[:version], @palette[:loading] = current.buffer.version, true
|
|
289
|
+
@palette[:items], @palette[:matches] = [], []
|
|
290
|
+
@palette.delete(:all_matches)
|
|
291
|
+
@palette.delete(:search)
|
|
292
|
+
@palette.delete(:indices)
|
|
293
|
+
document.request(syntax: true)
|
|
294
|
+
elsif document.syntax_ready? && (@palette[:loading] || !@palette[:items].equal?(document.outline))
|
|
295
|
+
symbols = document.outline
|
|
296
|
+
@palette[:items] = symbols
|
|
297
|
+
@palette[:matches] = symbols.map { |symbol| "#{' ' * symbol.depth}#{symbol.name}" }
|
|
298
|
+
@palette.delete(:all_matches)
|
|
299
|
+
@palette.delete(:search)
|
|
300
|
+
@palette[:loading], @palette[:partial] = false, !document.syntax_complete?
|
|
301
|
+
update_palette
|
|
302
|
+
@message = @palette[:partial] ? "Outline (visible region only)" : "Outline"
|
|
303
|
+
@window&.request_frame
|
|
304
|
+
elsif @palette[:loading] && !document.pending?
|
|
305
|
+
@palette[:loading] = false
|
|
306
|
+
@message = "Outline analysis unavailable"
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
return unless @pending_fold && @pending_fold[0].equal?(current)
|
|
310
|
+
_, requested_document, version, cursor = @pending_fold
|
|
311
|
+
unless current.equal?(editor) && requested_document.equal?(document) && version == current.buffer.version && cursor == current.primary.head
|
|
312
|
+
@pending_fold = nil
|
|
313
|
+
return
|
|
314
|
+
end
|
|
315
|
+
return if !document.syntax_ready? && document.pending?
|
|
316
|
+
@pending_fold = nil
|
|
317
|
+
if document.syntax_ready?
|
|
318
|
+
range = document.fold_ranges.reverse.find { |item| item.cover?(cursor) }
|
|
319
|
+
current.display_map.fold(range) if range
|
|
320
|
+
@message = range ? "Folded" : "No fold at cursor"
|
|
321
|
+
@window&.request_frame
|
|
322
|
+
else
|
|
323
|
+
@message = "Fold analysis unavailable"
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
private
|
|
328
|
+
def display_language_result(kind, result, client, current)
|
|
329
|
+
case kind
|
|
330
|
+
when :completion
|
|
331
|
+
items = result.is_a?(Hash) ? result.fetch("items", []) : Array(result)
|
|
332
|
+
defaults = result.is_a?(Hash) ? result.fetch("itemDefaults", {}) : {}
|
|
333
|
+
items = items.map do |item|
|
|
334
|
+
merged = defaults.reject { |key, _| key == "editRange" }.merge(item)
|
|
335
|
+
if defaults["editRange"] && !merged["textEdit"]
|
|
336
|
+
range = defaults["editRange"]
|
|
337
|
+
merged["textEdit"] = (range.key?("start") ? {"range" => range} : range).merge("newText" => item["textEditText"] || item["insertText"] || item.fetch("label"))
|
|
338
|
+
end
|
|
339
|
+
merged
|
|
340
|
+
end
|
|
341
|
+
self.palette = {kind: :completion, query: +"", index: 0, matches: items.map { |item| item.fetch("label") }, items: items, editor: current, client: client, version: current.buffer.version}
|
|
342
|
+
update_palette
|
|
343
|
+
when :hover, :signatureHelp
|
|
344
|
+
contents = result && (result["contents"] || result["signatures"]&.map { |signature| signature["label"] })
|
|
345
|
+
@hover_markup = kind == :hover && !(contents.is_a?(Hash) && contents["kind"] == "plaintext")
|
|
346
|
+
@hover_card = (contents.is_a?(Hash) ? [contents] : Array(contents)).map do |part|
|
|
347
|
+
if part.is_a?(Hash)
|
|
348
|
+
part["language"] ? "```#{part['language']}\n#{part['value']}\n```" : part["value"]
|
|
349
|
+
else
|
|
350
|
+
part
|
|
351
|
+
end
|
|
352
|
+
end.compact.join("\n")
|
|
353
|
+
when :definition, :typeDefinition, :implementation, :references, :workspace_symbols
|
|
354
|
+
items = result.is_a?(Hash) ? [result] : Array(result)
|
|
355
|
+
labels = items.map do |item|
|
|
356
|
+
location = item["location"] || item
|
|
357
|
+
uri = location["uri"] || location["targetUri"]
|
|
358
|
+
range = location["range"] || location["targetSelectionRange"]
|
|
359
|
+
"#{item['name']} #{File.basename(LSP::Protocol.path(uri))}:#{range.fetch('start').fetch('line') + 1}"
|
|
360
|
+
end
|
|
361
|
+
self.palette = {kind: :locations, query: +"", index: 0, matches: labels, items: items}
|
|
362
|
+
when :rename
|
|
363
|
+
if resource_workspace_edit?(result)
|
|
364
|
+
confirm_workspace_edit(result, label: "Apply rename and file operations?")
|
|
365
|
+
elsif result
|
|
366
|
+
apply_workspace_edit(result)
|
|
367
|
+
end
|
|
368
|
+
when :formatting then client.apply_text_edits(current.buffer, result || [])
|
|
369
|
+
when :codeAction, :codeLens
|
|
370
|
+
items = Array(result)
|
|
371
|
+
self.palette = {kind: :code_actions, query: +"", index: 0, matches: items.map { |item| item["title"] || item.dig("command", "title") || "Code lens" }, items: items, client: client, editor: current, version: current.buffer.version, lens: kind == :codeLens}
|
|
372
|
+
update_palette
|
|
373
|
+
when :documentSymbol
|
|
374
|
+
symbols = []
|
|
375
|
+
collect = lambda do |items, depth|
|
|
376
|
+
items.each do |item|
|
|
377
|
+
symbols << item.merge("_depth" => depth)
|
|
378
|
+
collect.call(item.fetch("children", []), depth + 1)
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
collect.call(Array(result), 0)
|
|
382
|
+
uri = LSP::Protocol.uri(current.buffer.path)
|
|
383
|
+
self.palette = {kind: :symbols, query: +"", index: 0, matches: symbols.map { |symbol| "#{' ' * symbol['_depth']}#{symbol['name']}" },
|
|
384
|
+
items: symbols.map { |symbol| symbol.merge("uri" => uri, "range" => symbol["selectionRange"] || symbol["range"]) }}
|
|
385
|
+
when :diagnostic
|
|
386
|
+
client.diagnostics[LSP::Protocol.uri(current.buffer.path)] = result.fetch("items", []) if result
|
|
387
|
+
when :semantic_tokens
|
|
388
|
+
types = client.capabilities.dig("semanticTokensProvider", "legend", "tokenTypes") || []
|
|
389
|
+
@semantic_styles ||= {}
|
|
390
|
+
@semantic_styles[current.buffer] = [current.buffer.version, result.map { |token| token.merge(name: types[token[:type]]) }]
|
|
391
|
+
when :inlayHint
|
|
392
|
+
hints = result || []
|
|
393
|
+
raise Error, "invalid inlay hints" unless hints.is_a?(Array)
|
|
394
|
+
raise Error, "too many inlay hints" if hints.length > 10_000
|
|
395
|
+
blocks = hints.map do |hint|
|
|
396
|
+
raise Error, "invalid inlay hint" unless hint.is_a?(Hash) && hint["position"].is_a?(Hash)
|
|
397
|
+
label = hint["label"]
|
|
398
|
+
if label.is_a?(Array)
|
|
399
|
+
raise Error, "invalid inlay hint label" unless label.all? { |part| part.is_a?(Hash) && part["value"].is_a?(String) && part["value"].valid_encoding? }
|
|
400
|
+
label = label.map { |part| part["value"] }.join
|
|
401
|
+
end
|
|
402
|
+
row = hint["position"]["line"]
|
|
403
|
+
raise Error, "invalid inlay hint" unless label.is_a?(String) && label.valid_encoding? && row.is_a?(Integer) && row.between?(0, current.buffer.line_count - 1)
|
|
404
|
+
[row, label]
|
|
405
|
+
end
|
|
406
|
+
map = current.display_map
|
|
407
|
+
map.block_map.blocks.keys.each { |id| map.remove_block(id) if id.is_a?(Array) && id.first == :inlay }
|
|
408
|
+
blocks.each_with_index { |(row, label), index| map.insert_block([:inlay, index], row: row, text: label, kind: :inlay) }
|
|
409
|
+
end
|
|
410
|
+
@window&.request_frame
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
end
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Canopus
|
|
4
|
+
module Workspace::LanguageServerConfigurable
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
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?
|
|
10
|
+
raise Error, "language server must be an argument array or command/options object"
|
|
11
|
+
end
|
|
12
|
+
command, env = value["command"], value.fetch("env", {})
|
|
13
|
+
unless command.is_a?(Array) && !command.empty? && command.first.is_a?(String) && !command.first.empty? &&
|
|
14
|
+
command.all? { |part| part.is_a?(String) && part.valid_encoding? && !part.include?("\0") }
|
|
15
|
+
raise Error, "language server command must be a nonempty argument array without NUL bytes"
|
|
16
|
+
end
|
|
17
|
+
unless env.is_a?(Hash) && env.all? { |key, item| key.is_a?(String) && !key.empty? && !key.match?(/[=\0]/) && (item.nil? || item.is_a?(String) && !item.include?("\0")) }
|
|
18
|
+
raise Error, "language server env must map names to strings or null"
|
|
19
|
+
end
|
|
20
|
+
configuration = value.fetch("configuration", {})
|
|
21
|
+
raise Error, "language server configuration must be an object" unless configuration.is_a?(Hash)
|
|
22
|
+
encoded = JSON.generate(value)
|
|
23
|
+
raise Error, "language server options exceed 1 MiB" if encoded.bytesize > 1 << 20
|
|
24
|
+
snapshot = JSON.parse(encoded)
|
|
25
|
+
freeze_value = lambda do |item|
|
|
26
|
+
item.each { |key, child| key.freeze; freeze_value.call(child) } if item.is_a?(Hash)
|
|
27
|
+
item.each { |child| freeze_value.call(child) } if item.is_a?(Array)
|
|
28
|
+
item.freeze
|
|
29
|
+
end
|
|
30
|
+
freeze_value.call(snapshot)
|
|
31
|
+
{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
|
|
34
|
+
raise Error, "invalid language server options: #{error.message}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def language_server_options(language)
|
|
38
|
+
values = @settings.for_language(language)
|
|
39
|
+
configured = values["language_servers"][language]
|
|
40
|
+
if configured.nil?
|
|
41
|
+
definition = @languages[language] || Language::DEFINITIONS.find { |item| item.name == language }
|
|
42
|
+
configured = definition&.servers&.find { |candidate| Language.executable?(candidate.first) }
|
|
43
|
+
end
|
|
44
|
+
normalize_server_options(configured) unless configured.nil?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
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
|
+
[@settings.values, *@settings["languages"].values].each do |layer|
|
|
51
|
+
layer.fetch("language_servers", {}).each do |name, value|
|
|
52
|
+
raise Error, "language server names must be strings" unless name.is_a?(String)
|
|
53
|
+
normalize_server_options(value) unless value.nil?
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
(@client_options || {}).keys.to_h { |language| [language, language_server_options(language)] }.freeze
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def reload_language_servers(targets)
|
|
60
|
+
return if @closed || targets.empty?
|
|
61
|
+
return if targets.all? { |language, options| @client_options[language] == options } && !@language_reload_job&.alive?
|
|
62
|
+
(@language_reload_lock ||= Mutex.new).synchronize do
|
|
63
|
+
@language_reload_targets = targets
|
|
64
|
+
return if @language_reload_job&.alive?
|
|
65
|
+
@language_reload_job = Thread.new do
|
|
66
|
+
loop do
|
|
67
|
+
pending = @language_reload_lock.synchronize do
|
|
68
|
+
values, @language_reload_targets = @language_reload_targets, nil
|
|
69
|
+
@language_reload_job = nil unless values
|
|
70
|
+
values
|
|
71
|
+
end
|
|
72
|
+
break unless pending
|
|
73
|
+
pending.each do |language, options|
|
|
74
|
+
break if @closed
|
|
75
|
+
(@client_lock ||= Mutex.new).synchronize { ensure_language_server(language, options) unless @closed }
|
|
76
|
+
rescue StandardError => error
|
|
77
|
+
post { @message = "Language server settings: #{error.message}" }
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def ensure_language_server(language, options)
|
|
85
|
+
raise Error, "Workspace closed" if @closed
|
|
86
|
+
@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
|
|
103
|
+
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
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
if Thread.current.equal?(@language_reload_job)
|
|
118
|
+
options = @language_reload_lock.synchronize do
|
|
119
|
+
@language_reload_targets&.key?(language) ? @language_reload_targets[language] : options
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
@client_options[language] = options
|
|
123
|
+
return unless options
|
|
124
|
+
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) }
|
|
128
|
+
replacement.on("workspace/configuration") do |params|
|
|
129
|
+
configured = @client_options.fetch(language).fetch(:configuration)
|
|
130
|
+
values = @settings.for_language(language).values
|
|
131
|
+
params.fetch("items").map do |item|
|
|
132
|
+
section = item["section"]
|
|
133
|
+
next configured unless section
|
|
134
|
+
value = configured.dig(*section.split("."))
|
|
135
|
+
value = values.dig(*section.split(".")) if value.nil?
|
|
136
|
+
value.nil? ? {} : value
|
|
137
|
+
end
|
|
138
|
+
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
|
|
151
|
+
begin
|
|
152
|
+
replacement.start
|
|
153
|
+
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
|
+
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
|
|
167
|
+
ensure
|
|
168
|
+
@starting_language_clients.delete(language)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def stop_language_servers
|
|
173
|
+
@starting_language_clients&.values&.each(&:stop)
|
|
174
|
+
(@client_lock ||= Mutex.new).synchronize do
|
|
175
|
+
@clients.each_value(&:stop)
|
|
176
|
+
@clients.clear
|
|
177
|
+
end
|
|
178
|
+
@language_reload_job&.join
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|