canopus 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +191 -19
  4. data/assets/keymaps/emacs.jsonc +5 -0
  5. data/assets/keymaps/jetbrains.jsonc +5 -0
  6. data/assets/keymaps/sublime.jsonc +5 -0
  7. data/assets/keymaps/vscode.jsonc +5 -0
  8. data/docs/debugging.md +143 -0
  9. data/docs/distribution.md +40 -9
  10. data/docs/lsp.md +131 -0
  11. data/docs/performance.md +18 -0
  12. data/docs/providers.md +36 -0
  13. data/docs/release-log.md +12 -0
  14. data/docs/tasks.md +64 -0
  15. data/docs/testing.md +49 -0
  16. data/docs/vim.md +3 -0
  17. data/exe/canopus +4 -1
  18. data/lib/canopus/buffer.rb +291 -50
  19. data/lib/canopus/cli.rb +5 -2
  20. data/lib/canopus/command.rb +13 -4
  21. data/lib/canopus/controller.rb +183 -40
  22. data/lib/canopus/debug/breakpoints.rb +550 -0
  23. data/lib/canopus/debug/configuration.rb +291 -0
  24. data/lib/canopus/debug/console.rb +243 -0
  25. data/lib/canopus/debug/panel.rb +487 -0
  26. data/lib/canopus/debug/session.rb +354 -0
  27. data/lib/canopus/decoration.rb +4 -2
  28. data/lib/canopus/denebola_compat.rb +27 -0
  29. data/lib/canopus/diagnostics.rb +173 -0
  30. data/lib/canopus/display_map/overlay_map.rb +6 -2
  31. data/lib/canopus/editor/snippet_expandable.rb +3 -2
  32. data/lib/canopus/editor.rb +122 -31
  33. data/lib/canopus/git/state.rb +100 -0
  34. data/lib/canopus/language/background_analysis.rb +93 -14
  35. data/lib/canopus/language/document.rb +70 -34
  36. data/lib/canopus/language/symbol.rb +1 -0
  37. data/lib/canopus/language/syntax_worker.rb +61 -9
  38. data/lib/canopus/language.rb +3 -0
  39. data/lib/canopus/minimap.rb +201 -0
  40. data/lib/canopus/patch.rb +3 -1
  41. data/lib/canopus/plugins/api.rb +4 -2
  42. data/lib/canopus/plugins/isolated_runtime.rb +64 -7
  43. data/lib/canopus/plugins/registry.rb +114 -3
  44. data/lib/canopus/plugins.rb +8 -7
  45. data/lib/canopus/project.rb +15 -6
  46. data/lib/canopus/provider.rb +192 -0
  47. data/lib/canopus/regexp_compat.rb +36 -4
  48. data/lib/canopus/selection.rb +12 -0
  49. data/lib/canopus/settings/schema.rb +201 -0
  50. data/lib/canopus/settings.rb +328 -49
  51. data/lib/canopus/task/configuration.rb +356 -0
  52. data/lib/canopus/task/problem_matcher.rb +264 -0
  53. data/lib/canopus/task/runner.rb +314 -0
  54. data/lib/canopus/test_runner/bounded_ignore.rb +144 -0
  55. data/lib/canopus/test_runner/discovery.rb +97 -0
  56. data/lib/canopus/test_runner/execution.rb +194 -0
  57. data/lib/canopus/test_runner/minitest.rb +116 -0
  58. data/lib/canopus/test_runner/rspec.rb +103 -0
  59. data/lib/canopus/test_runner/safe_walker.rb +37 -0
  60. data/lib/canopus/test_runner.rb +54 -0
  61. data/lib/canopus/theme/import.rb +131 -0
  62. data/lib/canopus/theme.rb +28 -4
  63. data/lib/canopus/version.rb +1 -1
  64. data/lib/canopus/vim/motionable.rb +1 -1
  65. data/lib/canopus/vim.rb +10 -16
  66. data/lib/canopus/workspace/auto_savable.rb +83 -0
  67. data/lib/canopus/workspace/debug_aware.rb +600 -0
  68. data/lib/canopus/workspace/edit/plan.rb +2 -2
  69. data/lib/canopus/workspace/editor_configurable.rb +113 -0
  70. data/lib/canopus/workspace/file_change_aware.rb +11 -3
  71. data/lib/canopus/workspace/git_aware.rb +80 -34
  72. data/lib/canopus/workspace/git_blame.rb +175 -0
  73. data/lib/canopus/workspace/git_conflict_resolution.rb +410 -0
  74. data/lib/canopus/workspace/git_diff_view.rb +641 -0
  75. data/lib/canopus/workspace/git_history.rb +171 -0
  76. data/lib/canopus/workspace/git_remote.rb +267 -0
  77. data/lib/canopus/workspace/git_staging.rb +583 -0
  78. data/lib/canopus/workspace/hierarchy_aware.rb +477 -0
  79. data/lib/canopus/workspace/keymap_aware.rb +66 -0
  80. data/lib/canopus/workspace/language_aware.rb +3721 -135
  81. data/lib/canopus/workspace/language_server_configurable.rb +287 -71
  82. data/lib/canopus/workspace/persistent_undo.rb +169 -0
  83. data/lib/canopus/workspace/problems_aware.rb +131 -0
  84. data/lib/canopus/workspace/project_searchable.rb +88 -11
  85. data/lib/canopus/workspace/project_tree_editable.rb +3 -1
  86. data/lib/canopus/workspace/recoverable.rb +204 -0
  87. data/lib/canopus/workspace/session_persistable.rb +85 -13
  88. data/lib/canopus/workspace/settings_aware.rb +76 -2
  89. data/lib/canopus/workspace/task_aware.rb +194 -0
  90. data/lib/canopus/workspace/test_aware.rb +394 -0
  91. data/lib/canopus/workspace/trust.rb +73 -0
  92. data/lib/canopus/workspace/view/terminal_presentable.rb +298 -47
  93. data/lib/canopus/workspace/view.rb +399 -74
  94. data/lib/canopus/workspace.rb +688 -44
  95. data/lib/canopus.rb +20 -2
  96. data/sig/canopus.rbs +138 -31
  97. data/sig/controller.rbs +10 -3
  98. data/sig/debug.rbs +121 -0
  99. data/sig/decoration.rbs +12 -2
  100. data/sig/diagnostics.rbs +26 -0
  101. data/sig/minimap.rbs +20 -0
  102. data/sig/provider.rbs +29 -0
  103. data/sig/services.rbs +1 -16
  104. data/sig/task.rbs +92 -0
  105. data/sig/test_runner.rbs +109 -0
  106. data/sig/trust.rbs +17 -0
  107. data/sig/workspace_edits.rbs +2 -2
  108. data/sig/workspace_services.rbs +234 -2
  109. data/tools/check_dependencies.rb +21 -2
  110. data/tools/package.rb +172 -14
  111. data/tools/package_signature_test.rb +33 -0
  112. data/tools/package_test.rb +29 -0
  113. data/tools/sign_package.rb +123 -0
  114. data/tools/update.rb +189 -0
  115. data/tools/update_test.rb +71 -0
  116. metadata +206 -31
  117. data/lib/canopus/lsp/client.rb +0 -307
  118. data/lib/canopus/lsp/error.rb +0 -7
  119. data/lib/canopus/lsp/future/subscription.rb +0 -9
  120. data/lib/canopus/lsp/future.rb +0 -87
  121. data/lib/canopus/lsp/protocol.rb +0 -83
  122. data/lib/canopus/lsp/server_error.rb +0 -13
  123. data/lib/canopus/lsp/timeout.rb +0 -7
  124. data/lib/canopus/lsp/transport.rb +0 -123
  125. data/lib/canopus/lsp.rb +0 -19
  126. data/lib/canopus/project/search.rb +0 -164
  127. data/lib/canopus/project/search_worker/cancelled.rb +0 -3
  128. data/lib/canopus/project/search_worker/runner.rb +0 -9
  129. data/lib/canopus/project/search_worker.rb +0 -108
  130. data/lib/canopus/terminal/cell.rb +0 -7
  131. data/lib/canopus/terminal/grid.rb +0 -321
  132. data/lib/canopus/terminal/pty.rb +0 -178
  133. data/lib/canopus/terminal/scrollback.rb +0 -38
  134. data/lib/canopus/terminal/vt.rb +0 -398
  135. data/lib/canopus/terminal.rb +0 -13
  136. data/sig/lsp.rbs +0 -99
  137. data/sig/terminal.rbs +0 -130
@@ -1,307 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Canopus
4
- module LSP
5
- class Client
6
- attr_reader :capabilities, :transport, :diagnostics, :state, :errors, :server_info, :position_encoding
7
- def initialize(command:, root: Dir.pwd, dispatch: ->(&block) { block.call }, restart: true, env: {}, initialization_options: nil, configuration: {})
8
- @command, @root, @dispatch, @restart = command, File.expand_path(root), dispatch, restart
9
- @env, @initialization_options, @configuration = env, initialization_options, configuration
10
- @pending, @handlers, @documents, @diagnostics, @semantic = {}, {}, {}, {}, {}
11
- @sequence, @lock, @state, @restarts, @epoch = 0, Mutex.new, :stopped, 0, 0
12
- @errors, @capabilities = [], {}
13
- end
14
- def start(timeout: 10)
15
- raise Error, "language server is already started" if [:starting, :running].include?(@state)
16
- @closing = false
17
- @state = :starting
18
- epoch = (@epoch += 1)
19
- @semantic.clear
20
- @transport = Transport.new(@command, cwd: @root, env: @env) { |message, error| receive(message, error, epoch) }
21
- result = request("initialize", {processId: Process.pid, rootUri: Protocol.uri(@root),
22
- clientInfo: {name: "Canopus", version: (defined?(Canopus::VERSION) ? Canopus::VERSION : "0.1.0")},
23
- workspaceFolders: [{uri: Protocol.uri(@root), name: File.basename(@root)}], initializationOptions: @initialization_options,
24
- capabilities: {general: {positionEncodings: ["utf-16"]},
25
- window: {workDoneProgress: true},
26
- textDocument: {synchronization: {dynamicRegistration: false, didSave: true},
27
- completion: {completionItem: {snippetSupport: true, documentationFormat: %w[markdown plaintext], resolveSupport: {properties: %w[documentation detail additionalTextEdits]}}},
28
- hover: {contentFormat: %w[markdown plaintext]},
29
- signatureHelp: {signatureInformation: {documentationFormat: %w[markdown plaintext], parameterInformation: {labelOffsetSupport: true}}},
30
- documentSymbol: {hierarchicalDocumentSymbolSupport: true},
31
- codeAction: {codeActionLiteralSupport: {codeActionKind: {valueSet: %w[quickfix refactor refactor.extract refactor.inline refactor.rewrite source source.organizeImports]}}, resolveSupport: {properties: ["edit"]}},
32
- publishDiagnostics: {relatedInformation: true, versionSupport: true},
33
- diagnostic: {dynamicRegistration: false, relatedDocumentSupport: false},
34
- inlayHint: {dynamicRegistration: false}, codeLens: {dynamicRegistration: false},
35
- semanticTokens: {requests: {full: {delta: true}}, tokenTypes: %w[namespace type class enum interface struct typeParameter parameter variable property enumMember event function method macro keyword modifier comment string number regexp operator decorator], tokenModifiers: %w[declaration definition readonly static deprecated abstract async modification documentation defaultLibrary], formats: ["relative"], overlappingTokenSupport: false, multilineTokenSupport: false}},
36
- workspace: {applyEdit: true, configuration: true, workspaceFolders: true,
37
- workspaceEdit: {documentChanges: true, resourceOperations: %w[create rename delete], failureHandling: "abort"}}}}).await(timeout: timeout)
38
- raise Error, "invalid initialize result" unless result.is_a?(Hash) && result["capabilities"].is_a?(Hash)
39
- @capabilities, @server_info = result["capabilities"], result["serverInfo"]
40
- sync = @capabilities["textDocumentSync"]
41
- mode = sync.is_a?(Hash) ? sync.fetch("change", 0) : sync
42
- raise Error, "invalid text document synchronization mode" unless mode.nil? || [0, 1, 2].include?(mode)
43
- @position_encoding = @capabilities.fetch("positionEncoding", "utf-16")
44
- raise Error, "server selected unadvertised position encoding #{@position_encoding}" unless @position_encoding == "utf-16"
45
- semantic = @capabilities["semanticTokensProvider"]
46
- raise Error, "missing semantic token legend" if semantic && (!semantic.is_a?(Hash) || !semantic["legend"].is_a?(Hash))
47
- Protocol.semantic_tokens([], legend: semantic["legend"]) if semantic.is_a?(Hash)
48
- notify("initialized", {})
49
- reopen_documents if @reopening_documents
50
- @state = :running
51
- self
52
- rescue StandardError => error
53
- @transport&.close if epoch
54
- fail_pending(error) if epoch
55
- @state = :failed if epoch
56
- raise
57
- end
58
- def request(method, params = {})
59
- id = @lock.synchronize { @sequence += 1 }
60
- future = Future.new(id, on_error: method(:report_error)) { |number| cancel(number) }
61
- @lock.synchronize { @pending[id] = future }
62
- raise Error, "language server is not connected" unless @transport&.alive?
63
- @transport.write(jsonrpc: "2.0", id: id, method: method.to_s, params: params)
64
- future
65
- rescue StandardError => error
66
- @lock.synchronize { @pending.delete(id) }
67
- future.fulfill(error: error)
68
- future
69
- end
70
- def notify(method, params = {})
71
- raise Error, "language server is not connected" unless @transport&.alive?
72
- @transport.write(jsonrpc: "2.0", method: method.to_s, params: params)
73
- end
74
- def on(method, &handler)
75
- raise ArgumentError, "handler required" unless handler
76
- @handlers[method.to_s] = handler
77
- end
78
- def supports?(capability) = !!@capabilities[capability.to_s]
79
- def workspace_symbols(query) = request("workspace/symbol", {query: query})
80
- def resolve_completion(item) = request("completionItem/resolve", item)
81
- def resolve_code_action(action) = request("codeAction/resolve", action)
82
- def resolve_code_lens(lens) = request("codeLens/resolve", lens)
83
- def execute_command(command, arguments: []) = request("workspace/executeCommand", {command: command, arguments: arguments})
84
-
85
- def open_document(buffer, language_id:)
86
- raise Error, "LSP document needs a path" unless buffer.path
87
- uri = Protocol.uri(buffer.path)
88
- @documents[uri]&.last&.detach
89
- subscription = buffer.on_edit { |patch| change_document(uri, buffer, patch) }
90
- @documents[uri] = [buffer, language_id, subscription]
91
- notify("textDocument/didOpen", {textDocument: {uri: uri, languageId: language_id, version: buffer.version, text: buffer.text}}) if open_close?
92
- uri
93
- end
94
- def close_document(uri)
95
- @documents.delete(uri)&.last&.detach
96
- @diagnostics.delete(uri)
97
- @semantic.delete(uri)
98
- notify("textDocument/didClose", {textDocument: {uri: uri}}) if open_close?
99
- end
100
- def save_document(uri)
101
- sync = @capabilities["textDocumentSync"]
102
- save = sync.is_a?(Hash) ? sync["save"] : sync.is_a?(Integer) && sync.positive?
103
- return unless save
104
- params = {textDocument: {uri: uri}}
105
- params[:text] = @documents.fetch(uri).first.text if save.is_a?(Hash) && save["includeText"]
106
- notify("textDocument/didSave", params)
107
- end
108
- def at(method, buffer, offset, **params)
109
- request("textDocument/#{method}", {textDocument: {uri: Protocol.uri(buffer.path)}, position: Protocol.position(buffer.rope, offset), **params})
110
- end
111
- %w[completion hover definition typeDefinition implementation references rename signatureHelp].each do |method|
112
- define_method(method) { |buffer, offset, **params| at(method, buffer, offset, **params) }
113
- end
114
- %w[documentSymbol formatting codeAction inlayHint codeLens diagnostic].each do |method|
115
- define_method(method) { |buffer, **params| request("textDocument/#{method}", {textDocument: {uri: Protocol.uri(buffer.path)}, **params}) }
116
- end
117
- def semantic_tokens(buffer)
118
- uri = Protocol.uri(buffer.path)
119
- version = buffer.version
120
- provider = @capabilities["semanticTokensProvider"]
121
- return [] unless provider.is_a?(Hash) && provider["full"]
122
- previous = @semantic[uri]
123
- delta = previous && previous[0] && provider["full"].is_a?(Hash) && provider["full"]["delta"]
124
- method = delta ? "textDocument/semanticTokens/full/delta" : "textDocument/semanticTokens/full"
125
- params = {textDocument: {uri: uri}}
126
- params[:previousResultId] = previous[0] if delta
127
- result = request(method, params).await
128
- return [] unless result && buffer.version == version
129
- raise Error, "invalid semantic token result" unless result.is_a?(Hash) && (!result.key?("resultId") || result["resultId"].is_a?(String))
130
- raise Error, "unexpected semantic token delta" if !delta && !result.key?("data")
131
- data = result["data"] || Protocol.semantic_delta(previous[1], result["edits"])
132
- tokens = Protocol.semantic_tokens(data, legend: provider["legend"])
133
- @semantic[uri] = [result["resultId"], data]
134
- tokens
135
- end
136
- def apply_text_edits(buffer, edits)
137
- changes = edits.map do |edit|
138
- raise Error, "invalid LSP text edit" unless edit.is_a?(Hash) && edit["range"].is_a?(Hash) && edit["newText"].is_a?(String) && edit["newText"].valid_encoding?
139
- range = edit.fetch("range")
140
- [Protocol.offset(buffer.rope, range.fetch("start"))...Protocol.offset(buffer.rope, range.fetch("end")), edit.fetch("newText")]
141
- end
142
- buffer.edit(changes, kind: :lsp)
143
- end
144
- def stop
145
- @closing = true
146
- begin
147
- request("shutdown").await(timeout: 2) if @state == :running
148
- notify("exit") if @transport&.alive?
149
- rescue Error
150
- nil
151
- ensure
152
- @epoch += 1
153
- @documents.each_value { |_, _, subscription| subscription.detach }
154
- @documents.clear
155
- @semantic.clear
156
- @diagnostics.clear
157
- @transport&.close
158
- fail_pending(Error.new("language server stopped"))
159
- @state = :stopped
160
- end
161
- end
162
-
163
- private
164
- def open_close?
165
- sync = @capabilities["textDocumentSync"]
166
- sync.is_a?(Hash) ? sync["openClose"] : sync.is_a?(Integer) && sync.positive?
167
- end
168
- def change_document(uri, buffer, patch)
169
- sync = @capabilities["textDocumentSync"]
170
- mode = sync.is_a?(Hash) ? sync["change"] : sync
171
- return unless [1, 2].include?(mode)
172
- changes = if mode == 2 && patch.is_a?(Patch)
173
- patch.edits.reverse.map { |edit| {range: Protocol.range(patch.before, edit.old_range), text: edit.new_text} }
174
- else
175
- [{text: buffer.text}]
176
- end
177
- notify("textDocument/didChange", {textDocument: {uri: uri, version: buffer.version}, contentChanges: changes})
178
- rescue Error => error
179
- report_error(error)
180
- end
181
- def cancel(id)
182
- @lock.synchronize { @pending.delete(id) }
183
- notify("$/cancelRequest", {id: id})
184
- rescue Error
185
- nil
186
- end
187
- def fail_pending(error)
188
- pending = @lock.synchronize { values = @pending.values; @pending.clear; values }
189
- pending.each { |future| future.fulfill(error: error) }
190
- end
191
- def report_error(error)
192
- bounded = Error.new("#{error.class}: #{error.message}".scrub.byteslice(0, 2048).scrub(""))
193
- @lock.synchronize do
194
- @errors << bounded
195
- @errors.shift if @errors.length > 200
196
- end
197
- @dispatch.call do
198
- begin
199
- @handlers["error"]&.call(bounded)
200
- rescue StandardError
201
- nil
202
- end
203
- end
204
- rescue StandardError
205
- nil
206
- end
207
- def receive(message, error, epoch = @epoch)
208
- return unless epoch == @epoch
209
- if error
210
- running = @state == :running
211
- @state = :failed
212
- fail_pending(Error.new(error.message))
213
- report_error(error)
214
- restart_server if running && @restart && !@closing && @restarts < 3
215
- elsif message.key?("id") && !message.key?("method")
216
- future = @lock.synchronize { @pending.delete(message["id"]) }
217
- future&.fulfill(message["result"], error: message["error"] && ServerError.new(message["error"]))
218
- elsif message["method"]
219
- method, params = message["method"], message.fetch("params", {})
220
- @dispatch.call do
221
- next unless epoch == @epoch && !@closing
222
- begin
223
- if method == "textDocument/publishDiagnostics"
224
- raise Error, "invalid diagnostics notification" unless params.is_a?(Hash) && params["uri"].is_a?(String) && params["diagnostics"].is_a?(Array)
225
- document = @documents[params["uri"]]
226
- version = params["version"]
227
- raise Error, "invalid diagnostic version" if !version.nil? && !version.is_a?(Integer)
228
- next if document && version && version < document.first.version
229
- @diagnostics[params["uri"]] = Protocol.diagnostics(params["diagnostics"])
230
- end
231
- known = @handlers.key?(method)
232
- result = @handlers[method]&.call(params)
233
- next unless message.key?("id")
234
- unless known
235
- known = true
236
- result = case method
237
- when "workspace/configuration"
238
- params.fetch("items").map do |item|
239
- section = item["section"]
240
- section ? @configuration.dig(*section.split(".")) : @configuration
241
- end
242
- when "workspace/workspaceFolders"
243
- [{uri: Protocol.uri(@root), name: File.basename(@root)}]
244
- when "window/workDoneProgress/create", "workspace/semanticTokens/refresh", "workspace/inlayHint/refresh", "workspace/codeLens/refresh", "workspace/diagnostic/refresh"
245
- @semantic.clear if method == "workspace/semanticTokens/refresh"
246
- nil
247
- else
248
- known = false
249
- nil
250
- end
251
- end
252
- if !known
253
- reply(message["id"], epoch, error: {code: -32601, message: "unsupported client request #{method}"})
254
- elsif result.is_a?(Future)
255
- result.then do |value, failure|
256
- failure ? reply(message["id"], epoch, error: {code: -32603, message: failure.message.byteslice(0, 2048).scrub}) : reply(message["id"], epoch, value: value)
257
- end
258
- else
259
- reply(message["id"], epoch, value: result)
260
- end
261
- rescue StandardError => failure
262
- report_error(failure)
263
- reply(message["id"], epoch, error: {code: -32603, message: "client request handler failed"}) if message.key?("id")
264
- end
265
- end
266
- end
267
- rescue StandardError => failure
268
- report_error(failure)
269
- reply(message["id"], epoch, error: {code: -32603, message: "client dispatch failed"}) if message&.key?("id") && message.key?("method")
270
- end
271
- def reply(id, epoch, value: nil, error: nil)
272
- return unless epoch == @epoch && !@closing
273
- response = {jsonrpc: "2.0", id: id}
274
- error ? response[:error] = error : response[:result] = value
275
- @transport.write(response)
276
- rescue StandardError => failure
277
- report_error(failure)
278
- end
279
- def restart_server
280
- return if @restart_thread&.alive?
281
- previous = @transport
282
- @restart_thread = Thread.new do
283
- @reopening_documents = true
284
- begin
285
- previous.close
286
- until @closing || @restarts >= 3
287
- @restarts += 1
288
- sleep(0.2 * @restarts)
289
- break if @closing
290
- begin
291
- start
292
- break
293
- rescue StandardError => failure
294
- report_error(failure)
295
- end
296
- end
297
- ensure
298
- @reopening_documents = false
299
- end
300
- end
301
- end
302
- def reopen_documents
303
- @documents.values.dup.each { |buffer, language, _| open_document(buffer, language_id: language) }
304
- end
305
- end
306
- end
307
- end
@@ -1,7 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Canopus
4
- module LSP
5
- class Error < StandardError; end
6
- end
7
- end
@@ -1,9 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class Canopus::LSP::Future::Subscription
4
- def initialize(&detach) = @detach = detach
5
- def detach
6
- callback, @detach = @detach, nil
7
- callback&.call
8
- end
9
- end
@@ -1,87 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Canopus
4
- module LSP
5
- class Future
6
- attr_reader :id, :callback_errors
7
- def initialize(id, on_error: nil, &cancel)
8
- @id, @cancel, @on_error, @lock, @ready = id, cancel, on_error, Mutex.new, ConditionVariable.new
9
- @callbacks, @callback_errors = [], []
10
- end
11
- def fulfill(value = nil, error: nil)
12
- callbacks = @lock.synchronize do
13
- return if @done
14
- @value, @error, @done = value, error, true
15
- @ready.broadcast
16
- saved, @callbacks = @callbacks, []
17
- saved
18
- end
19
- callbacks.each { |callback| invoke(callback) }
20
- self
21
- end
22
- def then(&callback)
23
- raise ArgumentError, "callback required" unless callback
24
- on_complete { callback.call(@value, @error) }
25
- self
26
- end
27
- def done? = @lock.synchronize { !!@done }
28
- def on_complete(&callback)
29
- raise ArgumentError, "callback required" unless callback
30
- ready = @lock.synchronize do
31
- @callbacks << callback unless @done
32
- @done
33
- end
34
- invoke(callback) if ready
35
- Subscription.new { @lock.synchronize { @callbacks.delete(callback) } }
36
- end
37
- def await(timeout: 10)
38
- raise ArgumentError, "timeout must be finite and nonnegative" unless timeout.nil? || (timeout.is_a?(Numeric) && timeout.finite? && timeout >= 0)
39
- if defined?(Zaniah::TaskExecutor) && Zaniah::TaskExecutor.current && !done?
40
- return Zaniah::TaskExecutor.current.await(self, timeout: timeout)
41
- end
42
- deadline = timeout && Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
43
- @lock.synchronize do
44
- until @done
45
- remaining = deadline && deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
46
- raise Timeout, "LSP request #{@id} timed out" if remaining && remaining <= 0
47
- @ready.wait(@lock, remaining)
48
- end
49
- raise @error if @error
50
- @value
51
- end
52
- rescue StandardError => error
53
- if error.is_a?(Timeout) || (defined?(Zaniah::Task::Timeout) && error.is_a?(Zaniah::Task::Timeout))
54
- cancel
55
- raise Timeout, "LSP request #{@id} timed out"
56
- end
57
- raise
58
- end
59
- def cancel
60
- callback = @lock.synchronize do
61
- return false if @done || @cancelling
62
- @cancelling = true
63
- @cancel
64
- end
65
- invoke(-> { callback.call(@id) }) if callback
66
- fulfill(error: Error.new("request cancelled"))
67
- true
68
- end
69
-
70
- private
71
- def invoke(callback)
72
- callback.call
73
- rescue StandardError => error
74
- bounded = Error.new("#{error.class}: #{error.message}".scrub.byteslice(0, 2048).scrub(""))
75
- @lock.synchronize do
76
- @callback_errors << bounded
77
- @callback_errors.shift if @callback_errors.length > 32
78
- end
79
- begin
80
- @on_error&.call(bounded)
81
- rescue StandardError
82
- nil
83
- end
84
- end
85
- end
86
- end
87
- end
@@ -1,83 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Canopus
4
- module LSP
5
- module Protocol
6
- module_function
7
- def uri(path)
8
- absolute = File.expand_path(path).tr("\\", "/")
9
- absolute = "/#{absolute}" if absolute.match?(/\A[A-Za-z]:/)
10
- "file://" + URI::RFC2396_PARSER.escape(absolute, /[^a-zA-Z0-9\-._~\/:]/)
11
- end
12
- def path(uri)
13
- parsed = URI.parse(uri)
14
- raise Error, "expected local file URI" unless parsed.scheme == "file" && [nil, "", "localhost"].include?(parsed.host) && parsed.query.nil? && parsed.fragment.nil? && parsed.path&.start_with?("/")
15
- path = URI::RFC2396_PARSER.unescape(parsed.path)
16
- raise Error, "invalid file URI path" if path.include?("\0") || !path.valid_encoding?
17
- RUBY_PLATFORM.match?(/mswin|mingw/) ? path.sub(%r{\A/([A-Za-z]:/)}, '\\1') : path
18
- rescue URI::InvalidURIError => error
19
- raise Error, error.message
20
- end
21
- def position(rope, offset)
22
- point = rope.utf16_point_at(offset)
23
- {line: point.row, character: point.column}
24
- end
25
- def offset(rope, position)
26
- raise Error, "invalid LSP position" unless position.is_a?(Hash) && uint?(position["line"]) && uint?(position["character"])
27
- row, column = position.fetch("line"), position.fetch("character")
28
- start = rope.line_start(row)
29
- finish = start + rope.line(row).bytesize
30
- units = rope.utf16_offset_at(finish) - rope.utf16_offset_at(start)
31
- rope.offset_at_utf16(rope.utf16_offset_at(start) + [column, units].min)
32
- end
33
- def range(rope, range)
34
- {start: position(rope, range.begin), end: position(rope, range.end + (range.exclude_end? ? 0 : 1))}
35
- end
36
- def semantic_delta(data, edits)
37
- raise Error, "invalid semantic token delta" unless data.is_a?(Array) && edits.is_a?(Array)
38
- output = data.dup
39
- last = 0
40
- edits.each do |edit|
41
- raise Error, "invalid semantic token delta" unless edit.is_a?(Hash) && uint?(edit["start"]) && uint?(edit["deleteCount"]) && edit.fetch("data", []).is_a?(Array)
42
- end
43
- sorted = edits.sort_by { |edit| edit.fetch("start") }
44
- sorted.each do |edit|
45
- start, count = edit.fetch("start"), edit.fetch("deleteCount")
46
- raise Error, "invalid semantic token delta" unless start.is_a?(Integer) && count.is_a?(Integer) && start >= last && count >= 0 && start + count <= data.length
47
- last = start + count
48
- end
49
- sorted.reverse_each { |edit| output[edit.fetch("start"), edit.fetch("deleteCount")] = edit.fetch("data", []) }
50
- semantic_tokens(output)
51
- output
52
- end
53
- def uint?(value) = value.is_a?(Integer) && value.between?(0, 0x7fffffff)
54
- def diagnostics(values)
55
- valid = values.is_a?(Array) && values.all? do |value|
56
- next false unless value.is_a?(Hash) && value["message"].is_a?(String) && value["range"].is_a?(Hash)
57
- range = value["range"]
58
- points = %w[start end].map { |key| range[key] }
59
- points.all? { |point| point.is_a?(Hash) && uint?(point["line"]) && uint?(point["character"]) } &&
60
- ([points[0]["line"], points[0]["character"]] <=> [points[1]["line"], points[1]["character"]]) <= 0 &&
61
- (!value.key?("severity") || (value["severity"].is_a?(Integer) && value["severity"].between?(1, 4)))
62
- end
63
- raise Error, "invalid LSP diagnostics" unless valid
64
- values
65
- end
66
- def semantic_tokens(data, legend: nil)
67
- raise Error, "invalid semantic token tuple count" unless data.is_a?(Array) && data.length % 5 == 0
68
- if legend
69
- raise Error, "invalid semantic token legend" unless legend.is_a?(Hash) && %w[tokenTypes tokenModifiers].all? { |key| legend[key].is_a?(Array) && legend[key].all? { |name| name.is_a?(String) } }
70
- end
71
- row, column = 0, 0
72
- data.each_slice(5).map do |delta_row, delta_column, length, type, modifiers|
73
- raise Error, "invalid semantic token value" unless [delta_row, delta_column, length, type, modifiers].all? { |v| uint?(v) } && length.positive?
74
- raise Error, "semantic token exceeds legend" if legend && (type >= legend["tokenTypes"].length || modifiers.bit_length > legend["tokenModifiers"].length)
75
- row += delta_row
76
- column = delta_row.zero? ? column + delta_column : delta_column
77
- raise Error, "semantic token position overflow" unless uint?(row) && uint?(column + length)
78
- {line: row, character: column, length: length, type: type, modifiers: modifiers}
79
- end
80
- end
81
- end
82
- end
83
- end
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Canopus
4
- module LSP
5
- class ServerError < Error
6
- attr_reader :code, :data
7
- def initialize(error)
8
- @code, @data = error["code"], error["data"]
9
- super(error["message"])
10
- end
11
- end
12
- end
13
- end
@@ -1,7 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Canopus
4
- module LSP
5
- class Timeout < Error; end
6
- end
7
- end
@@ -1,123 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Canopus
4
- module LSP
5
- class Transport
6
- MAX_MESSAGE = 32 << 20
7
- attr_reader :stderr_lines, :pid
8
- def initialize(command, cwd: nil, env: {}, &receive)
9
- raise ArgumentError, "command must be a nonempty argument array" unless command.is_a?(Array) && !command.empty? && command.all? { |part| part.is_a?(String) && !part.include?("\0") }
10
- raise ArgumentError, "receiver required" unless receive
11
- options = cwd ? {chdir: cwd} : {}
12
- @stdin, @stdout, @stderr, @process = Open3.popen3(env, *command, **options)
13
- @stdin.binmode
14
- @stdout.binmode
15
- @pid, @write_lock, @stderr_lines = @process.pid, Mutex.new, []
16
- @reader = Thread.new do
17
- begin
18
- loop do
19
- message = self.class.read_message(@stdout)
20
- break unless message
21
- receive.call(message, nil)
22
- end
23
- receive.call(nil, Error.new("language server closed stdout")) unless @closing
24
- rescue StandardError => error
25
- begin
26
- receive.call(nil, error) unless @closing
27
- rescue StandardError
28
- nil
29
- end
30
- end
31
- end
32
- @logger = Thread.new do
33
- while (line = @stderr.gets("\n", 8192))
34
- @stderr_lines << line.scrub.byteslice(0, 8192).scrub("")
35
- @stderr_lines.shift if @stderr_lines.length > 200
36
- end
37
- rescue IOError
38
- nil
39
- end
40
- end
41
- def self.read_message(io)
42
- headers, count = {}, 0
43
- loop do
44
- line = io.gets("\r\n", 8193)
45
- return nil if line.nil? && headers.empty?
46
- raise Error, "truncated or oversized LSP header" unless line && line.end_with?("\r\n") && line.bytesize <= 8192
47
- break if line == "\r\n"
48
- count += line.bytesize
49
- raise Error, "oversized LSP headers" if count > 16384
50
- raise Error, "non-ASCII LSP header" unless line.ascii_only?
51
- key, value = line.strip.split(":", 2)
52
- raise Error, "invalid LSP header" unless value && key.match?(/\A[A-Za-z][A-Za-z0-9-]*\z/)
53
- key = key.downcase
54
- raise Error, "duplicate LSP header" if headers.key?(key)
55
- headers[key] = value.strip
56
- end
57
- raw_length = headers["content-length"]
58
- raise Error, "missing or invalid Content-Length" unless raw_length&.match?(/\A\d+\z/)
59
- length = Integer(raw_length, 10)
60
- raise Error, "oversized LSP message" unless length.between?(1, MAX_MESSAGE)
61
- charset = headers["content-type"]&.match(/charset\s*=\s*"?([^;"\s]+)/i)&.[](1)
62
- raise Error, "unsupported LSP character encoding" if charset && !%w[utf-8 utf8].include?(charset.downcase)
63
- body = io.read(length)
64
- raise Error, "truncated LSP body" unless body && body.bytesize == length
65
- body.force_encoding(Encoding::UTF_8)
66
- raise Error, "invalid LSP UTF-8 body" unless body.valid_encoding?
67
- validate_message(JSON.parse(body))
68
- rescue JSON::ParserError => error
69
- raise Error, "invalid LSP JSON: #{error.message.byteslice(0, 256)}"
70
- end
71
- def self.validate_message(message)
72
- raise Error, "invalid JSON-RPC message" unless message.is_a?(Hash) && message["jsonrpc"] == "2.0"
73
- if message.key?("method")
74
- raise Error, "invalid JSON-RPC method" unless message["method"].is_a?(String) && !message["method"].empty?
75
- raise Error, "invalid JSON-RPC parameters" if message.key?("params") && !message["params"].is_a?(Hash) && !message["params"].is_a?(Array)
76
- raise Error, "request contains a response" if message.key?("result") || message.key?("error")
77
- else
78
- raise Error, "invalid JSON-RPC response" unless message.key?("id") && (message.key?("result") ^ message.key?("error"))
79
- if message.key?("error")
80
- error = message["error"]
81
- raise Error, "invalid JSON-RPC error" unless error.is_a?(Hash) && error["code"].is_a?(Integer) && error["message"].is_a?(String)
82
- end
83
- end
84
- id = message["id"]
85
- raise Error, "invalid JSON-RPC id" if message.key?("id") && !id.is_a?(Integer) && !id.is_a?(String) && !(id.nil? && !message.key?("method"))
86
- message
87
- end
88
- def write(message)
89
- raise Error, "expected JSON-RPC object" unless message.is_a?(Hash)
90
- normalized = message.transform_keys(&:to_s)
91
- normalized["error"] = normalized["error"].transform_keys(&:to_s) if normalized["error"].is_a?(Hash)
92
- self.class.validate_message(normalized)
93
- body = JSON.generate(message).b
94
- raise Error, "oversized LSP message" unless body.bytesize.between?(1, MAX_MESSAGE)
95
- @write_lock.synchronize do
96
- @stdin.write("Content-Length: #{body.bytesize}\r\n\r\n")
97
- @stdin.write(body)
98
- @stdin.flush
99
- end
100
- rescue IOError, Errno::EPIPE => error
101
- raise Error, "language server write failed: #{error.message}"
102
- end
103
- def alive? = @process.alive?
104
- def close
105
- return if @closing
106
- @closing = true
107
- @stdin.close unless @stdin.closed?
108
- unless @process.join(1)
109
- Process.kill("TERM", @pid) rescue Errno::ESRCH
110
- unless @process.join(1)
111
- Process.kill("KILL", @pid) rescue Errno::ESRCH
112
- @process.join
113
- end
114
- end
115
- [@stdout, @stderr].each { |io| io.close unless io.closed? }
116
- [@reader, @logger].each do |thread|
117
- next if thread == Thread.current
118
- thread.kill unless thread.join(1)
119
- end
120
- end
121
- end
122
- end
123
- end
data/lib/canopus/lsp.rb DELETED
@@ -1,19 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "open3"
5
- require "uri"
6
- require "thread"
7
-
8
- module Canopus
9
- module LSP; end
10
- end
11
-
12
- require_relative "lsp/error"
13
- require_relative "lsp/timeout"
14
- require_relative "lsp/server_error"
15
- require_relative "lsp/protocol"
16
- require_relative "lsp/future"
17
- require_relative "lsp/future/subscription"
18
- require_relative "lsp/transport"
19
- require_relative "lsp/client"