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.
Files changed (146) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +148 -0
  5. data/docs/adr/001-runtime-gem-components.md +24 -0
  6. data/docs/adr/002-bounded-diff-engine.md +22 -0
  7. data/docs/adr/003-background-language-analysis.md +22 -0
  8. data/docs/adr/004-persistent-display-map.md +23 -0
  9. data/docs/adr/005-windows-runtime.md +27 -0
  10. data/docs/adr/README.md +36 -0
  11. data/docs/distribution.md +27 -0
  12. data/docs/lsp.md +38 -0
  13. data/docs/performance.md +40 -0
  14. data/docs/snippets.md +32 -0
  15. data/docs/vim.md +24 -0
  16. data/docs/workspace_edits.md +32 -0
  17. data/examples/native_smoke.rb +25 -0
  18. data/examples/plugins/word_count.rb +11 -0
  19. data/exe/canopus +19 -0
  20. data/lib/canopus/block_map.rb +19 -0
  21. data/lib/canopus/buffer.rb +293 -0
  22. data/lib/canopus/cli.rb +171 -0
  23. data/lib/canopus/controller.rb +507 -0
  24. data/lib/canopus/data_compat.rb +25 -0
  25. data/lib/canopus/display_map/line_builder.rb +54 -0
  26. data/lib/canopus/display_map/line_set.rb +8 -0
  27. data/lib/canopus/display_map/pending_line_set.rb +9 -0
  28. data/lib/canopus/display_map/summary.rb +23 -0
  29. data/lib/canopus/display_map/worker.rb +89 -0
  30. data/lib/canopus/display_map.rb +350 -0
  31. data/lib/canopus/display_point.rb +5 -0
  32. data/lib/canopus/editor/snippet_expandable.rb +281 -0
  33. data/lib/canopus/editor.rb +445 -0
  34. data/lib/canopus/error.rb +5 -0
  35. data/lib/canopus/fold_map.rb +87 -0
  36. data/lib/canopus/git/blame.rb +50 -0
  37. data/lib/canopus/git/commit.rb +7 -0
  38. data/lib/canopus/git/corrupt_object.rb +7 -0
  39. data/lib/canopus/git/diff.rb +131 -0
  40. data/lib/canopus/git/index.rb +94 -0
  41. data/lib/canopus/git/object_database.rb +45 -0
  42. data/lib/canopus/git/pack.rb +186 -0
  43. data/lib/canopus/git/repository.rb +313 -0
  44. data/lib/canopus/git/status.rb +74 -0
  45. data/lib/canopus/git/tree_entry.rb +7 -0
  46. data/lib/canopus/git.rb +15 -0
  47. data/lib/canopus/icon_theme.rb +43 -0
  48. data/lib/canopus/language/background_analysis/job.rb +12 -0
  49. data/lib/canopus/language/background_analysis/scheduler.rb +70 -0
  50. data/lib/canopus/language/background_analysis.rb +280 -0
  51. data/lib/canopus/language/definition.rb +7 -0
  52. data/lib/canopus/language/document.rb +143 -0
  53. data/lib/canopus/language/symbol.rb +7 -0
  54. data/lib/canopus/language/syntax_worker.rb +91 -0
  55. data/lib/canopus/language.rb +42 -0
  56. data/lib/canopus/lazy_rope.rb +218 -0
  57. data/lib/canopus/lsp/client.rb +299 -0
  58. data/lib/canopus/lsp/error.rb +7 -0
  59. data/lib/canopus/lsp/future/subscription.rb +9 -0
  60. data/lib/canopus/lsp/future.rb +87 -0
  61. data/lib/canopus/lsp/protocol.rb +83 -0
  62. data/lib/canopus/lsp/server_error.rb +13 -0
  63. data/lib/canopus/lsp/timeout.rb +7 -0
  64. data/lib/canopus/lsp/transport.rb +123 -0
  65. data/lib/canopus/lsp.rb +19 -0
  66. data/lib/canopus/markdown.rb +69 -0
  67. data/lib/canopus/match_data_compat.rb +17 -0
  68. data/lib/canopus/multi_buffer.rb +266 -0
  69. data/lib/canopus/pane.rb +61 -0
  70. data/lib/canopus/patch/composite.rb +12 -0
  71. data/lib/canopus/patch/reload.rb +18 -0
  72. data/lib/canopus/patch.rb +27 -0
  73. data/lib/canopus/performance_recorder.rb +207 -0
  74. data/lib/canopus/plugins/api.rb +50 -0
  75. data/lib/canopus/plugins/isolated_runtime.rb +167 -0
  76. data/lib/canopus/plugins/local_runtime.rb +25 -0
  77. data/lib/canopus/plugins/permission_denied.rb +7 -0
  78. data/lib/canopus/plugins/registry.rb +30 -0
  79. data/lib/canopus/plugins.rb +11 -0
  80. data/lib/canopus/project/ignore_matcher.rb +104 -0
  81. data/lib/canopus/project/search.rb +164 -0
  82. data/lib/canopus/project/search_worker/cancelled.rb +3 -0
  83. data/lib/canopus/project/search_worker/runner.rb +9 -0
  84. data/lib/canopus/project/search_worker.rb +108 -0
  85. data/lib/canopus/project/tree.rb +48 -0
  86. data/lib/canopus/project/watcher.rb +59 -0
  87. data/lib/canopus/project.rb +127 -0
  88. data/lib/canopus/regexp_compat.rb +30 -0
  89. data/lib/canopus/save_conflict.rb +5 -0
  90. data/lib/canopus/selection.rb +11 -0
  91. data/lib/canopus/settings.rb +118 -0
  92. data/lib/canopus/snippet/transform.rb +209 -0
  93. data/lib/canopus/snippet.rb +183 -0
  94. data/lib/canopus/tab_map.rb +30 -0
  95. data/lib/canopus/terminal/cell.rb +7 -0
  96. data/lib/canopus/terminal/grid.rb +321 -0
  97. data/lib/canopus/terminal/pty.rb +103 -0
  98. data/lib/canopus/terminal/scrollback.rb +38 -0
  99. data/lib/canopus/terminal/vt.rb +398 -0
  100. data/lib/canopus/terminal.rb +13 -0
  101. data/lib/canopus/theme.rb +43 -0
  102. data/lib/canopus/version.rb +5 -0
  103. data/lib/canopus/vim/commandable.rb +148 -0
  104. data/lib/canopus/vim/motionable.rb +321 -0
  105. data/lib/canopus/vim/operator_capable.rb +377 -0
  106. data/lib/canopus/vim/text_object_selectable.rb +141 -0
  107. data/lib/canopus/vim.rb +426 -0
  108. data/lib/canopus/workspace/edit/executable.rb +139 -0
  109. data/lib/canopus/workspace/edit/node.rb +8 -0
  110. data/lib/canopus/workspace/edit/plan.rb +170 -0
  111. data/lib/canopus/workspace/edit/resource_preparable.rb +98 -0
  112. data/lib/canopus/workspace/edit.rb +6 -0
  113. data/lib/canopus/workspace/file_change_aware.rb +40 -0
  114. data/lib/canopus/workspace/file_previewable.rb +31 -0
  115. data/lib/canopus/workspace/git_aware.rb +140 -0
  116. data/lib/canopus/workspace/language_aware.rb +413 -0
  117. data/lib/canopus/workspace/language_server_configurable.rb +181 -0
  118. data/lib/canopus/workspace/project_searchable.rb +208 -0
  119. data/lib/canopus/workspace/project_tree_editable.rb +82 -0
  120. data/lib/canopus/workspace/session_persistable.rb +153 -0
  121. data/lib/canopus/workspace/settings_aware.rb +91 -0
  122. data/lib/canopus/workspace/view/terminal_presentable.rb +185 -0
  123. data/lib/canopus/workspace/view.rb +643 -0
  124. data/lib/canopus/workspace.rb +525 -0
  125. data/lib/canopus/wrap_map.rb +108 -0
  126. data/lib/canopus.rb +24 -0
  127. data/sig/canopus.rbs +375 -0
  128. data/sig/controller.rbs +55 -0
  129. data/sig/display_stages.rbs +49 -0
  130. data/sig/git.rbs +140 -0
  131. data/sig/lsp.rbs +99 -0
  132. data/sig/markdown.rbs +11 -0
  133. data/sig/performance_recorder.rbs +23 -0
  134. data/sig/search_services.rbs +8 -0
  135. data/sig/services.rbs +81 -0
  136. data/sig/snippet.rbs +42 -0
  137. data/sig/terminal.rbs +125 -0
  138. data/sig/vim.rbs +27 -0
  139. data/sig/workspace_edits.rbs +11 -0
  140. data/sig/workspace_services.rbs +117 -0
  141. data/tools/certify_snippet_regex.rb +35 -0
  142. data/tools/check_dependencies.rb +80 -0
  143. data/tools/native_check.rb +70 -0
  144. data/tools/package.rb +113 -0
  145. data/tools/package_test.rb +32 -0
  146. 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