sadr 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 +62 -0
- data/docs/adr/000-template.md +17 -0
- data/docs/adr/001-editor-independent-documents.md +20 -0
- data/docs/adr/README.md +3 -0
- data/lib/sadr/client.rb +529 -0
- data/lib/sadr/document_sync.rb +80 -0
- data/lib/sadr/future.rb +118 -0
- data/lib/sadr/protocol.rb +208 -0
- data/lib/sadr/transport.rb +166 -0
- data/lib/sadr/version.rb +5 -0
- data/lib/sadr.rb +55 -0
- data/sig/sadr.rbs +152 -0
- metadata +57 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: fa9d3261b52e409bc33e40c02fbf3e3659d701a70115c6cd5784a106232f5669
|
|
4
|
+
data.tar.gz: db606ffa6c2444b3ab23a38fdcefa285a80fa6fb6956590a369b30f628d316db
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 457a517a32c63de12ab4a3461c36f34a6270e7e77ec05b7c4beb9545c0b3a31c1b01b43a41fbcbffd2fa93f08a2987541b830eda8195f9f60ae576c6b1b8c0b7
|
|
7
|
+
data.tar.gz: 53313130ecb14ae934774fe9c8a442a53bb6791fc75896f9ead392b4c370e27ca3ecffdc819981e4503a94919b35a41984bd06581e469f7f9dc9354030ecc041
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yudai Takada
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Sadr
|
|
2
|
+
|
|
3
|
+
Sadr is a pure Ruby Language Server Protocol client. It owns JSON-RPC framing,
|
|
4
|
+
server lifecycle, document synchronization, diagnostics, and semantic tokens
|
|
5
|
+
without depending on an editor model or text-storage gem.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "sadr"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
require "sadr"
|
|
17
|
+
|
|
18
|
+
client = Sadr::Client.new(command: ["ruby-lsp"]).start
|
|
19
|
+
document = Sadr::Document.new(
|
|
20
|
+
uri: Sadr::Protocol.uri("example.rb"),
|
|
21
|
+
language_id: "ruby",
|
|
22
|
+
version: 0,
|
|
23
|
+
text: "puts :hello\n"
|
|
24
|
+
)
|
|
25
|
+
client.open(document)
|
|
26
|
+
|
|
27
|
+
position = Sadr::Position.new(line: 0, character: 0)
|
|
28
|
+
completion = client.completion(document.uri, position).await
|
|
29
|
+
client.stop
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`Position#character` is measured in UTF-16 code units. Increment versions and
|
|
33
|
+
send immutable changes when a document changes:
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
client.change(document.uri, 1, [
|
|
37
|
+
Sadr::ContentChange.new(range: nil, text: "puts :world\n")
|
|
38
|
+
])
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Protocol conversion accepts any text index that provides `utf16_point_at`,
|
|
42
|
+
`utf16_offset_at`, `offset_at_utf16`, `line_start`, and `line`. This keeps rope
|
|
43
|
+
and buffer ownership in the caller:
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
position = Sadr::Protocol.position(my_text_index, byte_offset)
|
|
47
|
+
edits = Sadr::Protocol.text_edits(my_text_index, server_edits)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Development
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
bundle install
|
|
54
|
+
bundle exec rake test
|
|
55
|
+
bundle exec rbs -I sig -r stringio validate
|
|
56
|
+
BUDGET=1 bundle exec rake bench
|
|
57
|
+
gem build --strict sadr.gemspec
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## License
|
|
61
|
+
|
|
62
|
+
Sadr is available under the MIT License.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# ADR NNN: Implementation decision title
|
|
2
|
+
|
|
3
|
+
- Status: Proposed
|
|
4
|
+
- Date: YYYY-MM-DD
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Describe the concrete implementation question and its compatibility, data,
|
|
9
|
+
runtime, or component constraints.
|
|
10
|
+
|
|
11
|
+
## Decision
|
|
12
|
+
|
|
13
|
+
Describe the durable boundary or architecture choice.
|
|
14
|
+
|
|
15
|
+
## Consequences
|
|
16
|
+
|
|
17
|
+
Describe the important positive and negative trade-offs and when to revisit it.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# ADR 001: Keep editor documents outside Sadr
|
|
2
|
+
|
|
3
|
+
- Status: Accepted
|
|
4
|
+
- Date: 2026-09-14
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
An LSP client needs document text, versions, and UTF-16 positions. Depending on
|
|
9
|
+
an editor buffer or rope would couple protocol transport to one application.
|
|
10
|
+
|
|
11
|
+
## Decision
|
|
12
|
+
|
|
13
|
+
Accept immutable URI-based documents and changes. Accept text indexes through
|
|
14
|
+
the five operations required for UTF-16 conversion, without a runtime gem
|
|
15
|
+
dependency.
|
|
16
|
+
|
|
17
|
+
## Consequences
|
|
18
|
+
|
|
19
|
+
Sadr can be embedded in any Ruby application. Callers remain responsible for
|
|
20
|
+
their editor model and for applying server text edits.
|
data/docs/adr/README.md
ADDED
data/lib/sadr/client.rb
ADDED
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sadr
|
|
4
|
+
class Client
|
|
5
|
+
POSITION_METHODS = {
|
|
6
|
+
completion: "completion",
|
|
7
|
+
hover: "hover",
|
|
8
|
+
definition: "definition",
|
|
9
|
+
type_definition: "typeDefinition",
|
|
10
|
+
implementation: "implementation",
|
|
11
|
+
signature_help: "signatureHelp"
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :capabilities, :transport, :diagnostics, :state, :errors, :server_info, :position_encoding
|
|
15
|
+
|
|
16
|
+
def initialize(command:, root: Dir.pwd, dispatch: ->(&block) { block.call }, restart: true, env: {}, initialization_options: nil, configuration: {})
|
|
17
|
+
@command = command
|
|
18
|
+
@root = File.expand_path(root)
|
|
19
|
+
@dispatch = dispatch
|
|
20
|
+
@restart = restart
|
|
21
|
+
@env = env
|
|
22
|
+
@initialization_options = initialization_options
|
|
23
|
+
@configuration = configuration
|
|
24
|
+
@pending = {}
|
|
25
|
+
@handlers = {}
|
|
26
|
+
@documents = {}
|
|
27
|
+
@diagnostics = {}
|
|
28
|
+
@semantic = {}
|
|
29
|
+
@sequence = 0
|
|
30
|
+
@lock = Mutex.new
|
|
31
|
+
@state = :stopped
|
|
32
|
+
@restarts = 0
|
|
33
|
+
@epoch = 0
|
|
34
|
+
@errors = []
|
|
35
|
+
@capabilities = {}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def start(timeout: 10)
|
|
39
|
+
raise Error, "language server is already started" if %i[starting running].include?(@state)
|
|
40
|
+
|
|
41
|
+
@closing = false
|
|
42
|
+
@state = :starting
|
|
43
|
+
epoch = (@epoch += 1)
|
|
44
|
+
@semantic.clear
|
|
45
|
+
@transport = Transport.new(@command, cwd: @root, env: @env) do |message, error|
|
|
46
|
+
receive(message, error, epoch)
|
|
47
|
+
end
|
|
48
|
+
result = request("initialize", initialize_params).await(timeout: timeout)
|
|
49
|
+
raise Error, "invalid initialize result" unless result.is_a?(Hash) && result["capabilities"].is_a?(Hash)
|
|
50
|
+
|
|
51
|
+
@capabilities = result["capabilities"]
|
|
52
|
+
@server_info = result["serverInfo"]
|
|
53
|
+
validate_capabilities
|
|
54
|
+
notify("initialized", {})
|
|
55
|
+
@state = :running
|
|
56
|
+
self
|
|
57
|
+
rescue StandardError => error
|
|
58
|
+
@transport&.close if epoch
|
|
59
|
+
fail_pending(error) if epoch
|
|
60
|
+
@state = :failed if epoch
|
|
61
|
+
raise
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def stop
|
|
65
|
+
@closing = true
|
|
66
|
+
begin
|
|
67
|
+
request("shutdown").await(timeout: 2) if @state == :running
|
|
68
|
+
notify("exit") if @transport&.alive?
|
|
69
|
+
rescue Error
|
|
70
|
+
nil
|
|
71
|
+
ensure
|
|
72
|
+
@epoch += 1
|
|
73
|
+
@documents.clear
|
|
74
|
+
@semantic.clear
|
|
75
|
+
@diagnostics.clear
|
|
76
|
+
@transport&.close
|
|
77
|
+
fail_pending(Error.new("language server stopped"))
|
|
78
|
+
@state = :stopped
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def running? = @state == :running && !!@transport&.alive?
|
|
83
|
+
|
|
84
|
+
def request(method, params = {})
|
|
85
|
+
id = @lock.synchronize { @sequence += 1 }
|
|
86
|
+
future = Future.new(id, on_error: method(:report_error)) { |number| cancel(number) }
|
|
87
|
+
@lock.synchronize { @pending[id] = future }
|
|
88
|
+
raise Error, "language server is not connected" unless @transport&.alive?
|
|
89
|
+
|
|
90
|
+
@transport.write(jsonrpc: "2.0", id: id, method: method.to_s, params: params)
|
|
91
|
+
future
|
|
92
|
+
rescue StandardError => error
|
|
93
|
+
@lock.synchronize { @pending.delete(id) }
|
|
94
|
+
future.fulfill(error: error)
|
|
95
|
+
future
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def notify(method, params = {})
|
|
99
|
+
raise Error, "language server is not connected" unless @transport&.alive?
|
|
100
|
+
|
|
101
|
+
@transport.write(jsonrpc: "2.0", method: method.to_s, params: params)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def on(method, &handler)
|
|
105
|
+
raise ArgumentError, "handler required" unless handler
|
|
106
|
+
|
|
107
|
+
@handlers[method.to_s] = handler
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def supports?(capability) = !!@capabilities[capability.to_s]
|
|
111
|
+
|
|
112
|
+
def open(document)
|
|
113
|
+
validate_document(document)
|
|
114
|
+
stored = Document.new(uri: document.uri.dup.freeze, language_id: document.language_id.dup.freeze,
|
|
115
|
+
version: document.version, text: document.text.dup.freeze)
|
|
116
|
+
@documents[stored.uri] = stored
|
|
117
|
+
notify_open(stored) if open_close?
|
|
118
|
+
stored.uri
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def change(uri, version, changes)
|
|
122
|
+
document = @documents.fetch(uri) { raise Error, "document is not open" }
|
|
123
|
+
unless Protocol.uint?(version) && version > document.version
|
|
124
|
+
raise Error, "document version must increase"
|
|
125
|
+
end
|
|
126
|
+
raise Error, "content changes must be a nonempty Array" unless changes.is_a?(Array) && !changes.empty?
|
|
127
|
+
|
|
128
|
+
text = document.text
|
|
129
|
+
wire_changes = changes.map do |change|
|
|
130
|
+
validate_change(change)
|
|
131
|
+
if change.range
|
|
132
|
+
index = DocumentIndex.new(text)
|
|
133
|
+
first = Protocol.offset(index, change.range.start)
|
|
134
|
+
last = Protocol.offset(index, change.range.end)
|
|
135
|
+
raise Error, "invalid content change range" if last < first
|
|
136
|
+
|
|
137
|
+
text = text.byteslice(0, first) + change.text + text.byteslice(last, text.bytesize - last)
|
|
138
|
+
{range: Protocol.range_hash(change.range), text: change.text}
|
|
139
|
+
else
|
|
140
|
+
text = change.text
|
|
141
|
+
{text: change.text}
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
updated = Document.new(uri: document.uri, language_id: document.language_id, version: version, text: text.freeze)
|
|
145
|
+
@documents[uri] = updated
|
|
146
|
+
|
|
147
|
+
mode = sync_mode
|
|
148
|
+
return updated unless [1, 2].include?(mode)
|
|
149
|
+
|
|
150
|
+
content_changes = mode == 2 ? wire_changes : [{text: text}]
|
|
151
|
+
notify("textDocument/didChange", {textDocument: {uri: uri, version: version}, contentChanges: content_changes})
|
|
152
|
+
updated
|
|
153
|
+
rescue KeyError
|
|
154
|
+
raise Error, "document is not open"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def save(uri, text: nil)
|
|
158
|
+
document = @documents.fetch(uri) { raise Error, "document is not open" }
|
|
159
|
+
sync = @capabilities["textDocumentSync"]
|
|
160
|
+
save = sync.is_a?(Hash) ? sync["save"] : sync.is_a?(Integer) && sync.positive?
|
|
161
|
+
return unless save
|
|
162
|
+
|
|
163
|
+
if text
|
|
164
|
+
raise Error, "saved text must be valid UTF-8" unless text.is_a?(String) && text.valid_encoding?
|
|
165
|
+
end
|
|
166
|
+
params = {textDocument: {uri: uri}}
|
|
167
|
+
params[:text] = text || document.text if save.is_a?(Hash) && save["includeText"]
|
|
168
|
+
notify("textDocument/didSave", params)
|
|
169
|
+
rescue KeyError
|
|
170
|
+
raise Error, "document is not open"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def close(uri)
|
|
174
|
+
raise Error, "document is not open" unless @documents.delete(uri)
|
|
175
|
+
|
|
176
|
+
@diagnostics.delete(uri)
|
|
177
|
+
@semantic.delete(uri)
|
|
178
|
+
notify("textDocument/didClose", {textDocument: {uri: uri}}) if open_close?
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
POSITION_METHODS.each do |ruby_name, lsp_name|
|
|
182
|
+
define_method(ruby_name) do |uri, position, **params|
|
|
183
|
+
request_at(lsp_name, uri, position, params)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def references(uri, position, include_declaration: true)
|
|
188
|
+
request_at("references", uri, position, context: {includeDeclaration: !!include_declaration})
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def rename(uri, position, new_name)
|
|
192
|
+
raise Error, "new name must be a String" unless new_name.is_a?(String) && new_name.valid_encoding?
|
|
193
|
+
|
|
194
|
+
request_at("rename", uri, position, newName: new_name)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def document_symbol(uri) = request_document("documentSymbol", uri)
|
|
198
|
+
|
|
199
|
+
def formatting(uri, options)
|
|
200
|
+
raise Error, "formatting options must be an object" unless options.is_a?(Hash)
|
|
201
|
+
|
|
202
|
+
request_document("formatting", uri, options: options)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def code_action(uri, range, context)
|
|
206
|
+
raise Error, "code action context must be an object" unless context.is_a?(Hash)
|
|
207
|
+
|
|
208
|
+
request_document("codeAction", uri, range: Protocol.range_hash(range), context: context)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def code_lens(uri) = request_document("codeLens", uri)
|
|
212
|
+
def inlay_hint(uri, range) = request_document("inlayHint", uri, range: Protocol.range_hash(range))
|
|
213
|
+
|
|
214
|
+
def diagnostic(uri, previous_result_id: nil)
|
|
215
|
+
params = {}
|
|
216
|
+
if previous_result_id
|
|
217
|
+
raise Error, "previous result id must be a String" unless previous_result_id.is_a?(String)
|
|
218
|
+
|
|
219
|
+
params[:previousResultId] = previous_result_id
|
|
220
|
+
end
|
|
221
|
+
request_document("diagnostic", uri, **params)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def semantic_tokens(uri, version:)
|
|
225
|
+
document = @documents[uri]
|
|
226
|
+
return [] unless document && document.version == version
|
|
227
|
+
|
|
228
|
+
provider = @capabilities["semanticTokensProvider"]
|
|
229
|
+
return [] unless provider.is_a?(Hash) && provider["full"]
|
|
230
|
+
|
|
231
|
+
previous = @semantic[uri]
|
|
232
|
+
delta = previous && previous[0] && provider["full"].is_a?(Hash) && provider["full"]["delta"]
|
|
233
|
+
method = delta ? "textDocument/semanticTokens/full/delta" : "textDocument/semanticTokens/full"
|
|
234
|
+
params = {textDocument: {uri: uri}}
|
|
235
|
+
params[:previousResultId] = previous[0] if delta
|
|
236
|
+
result = request(method, params).await
|
|
237
|
+
current = @documents[uri]
|
|
238
|
+
return [] unless result && current && current.version == version
|
|
239
|
+
|
|
240
|
+
valid = result.is_a?(Hash) && (!result.key?("resultId") || result["resultId"].is_a?(String))
|
|
241
|
+
raise Error, "invalid semantic token result" unless valid
|
|
242
|
+
raise Error, "unexpected semantic token delta" if !delta && !result.key?("data")
|
|
243
|
+
|
|
244
|
+
data = result["data"] || Protocol.semantic_delta(previous[1], result["edits"])
|
|
245
|
+
tokens = Protocol.semantic_tokens(data, legend: provider["legend"])
|
|
246
|
+
@semantic[uri] = [result["resultId"], data]
|
|
247
|
+
tokens
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def workspace_symbols(query)
|
|
251
|
+
raise Error, "query must be a String" unless query.is_a?(String)
|
|
252
|
+
|
|
253
|
+
request("workspace/symbol", {query: query})
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def resolve_completion(item) = resolve("completionItem/resolve", item)
|
|
257
|
+
def resolve_code_action(action) = resolve("codeAction/resolve", action)
|
|
258
|
+
def resolve_code_lens(lens) = resolve("codeLens/resolve", lens)
|
|
259
|
+
|
|
260
|
+
def execute_command(command, arguments: [])
|
|
261
|
+
raise Error, "command must be a nonempty String" unless command.is_a?(String) && !command.empty?
|
|
262
|
+
raise Error, "arguments must be an Array" unless arguments.is_a?(Array)
|
|
263
|
+
|
|
264
|
+
request("workspace/executeCommand", {command: command, arguments: arguments})
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
private
|
|
268
|
+
|
|
269
|
+
def initialize_params
|
|
270
|
+
{
|
|
271
|
+
processId: Process.pid,
|
|
272
|
+
rootUri: Protocol.uri(@root),
|
|
273
|
+
clientInfo: {name: "Sadr", version: VERSION},
|
|
274
|
+
workspaceFolders: [{uri: Protocol.uri(@root), name: File.basename(@root)}],
|
|
275
|
+
initializationOptions: @initialization_options,
|
|
276
|
+
capabilities: {
|
|
277
|
+
general: {positionEncodings: ["utf-16"]},
|
|
278
|
+
window: {workDoneProgress: true},
|
|
279
|
+
textDocument: {
|
|
280
|
+
synchronization: {dynamicRegistration: false, didSave: true},
|
|
281
|
+
completion: {completionItem: {snippetSupport: true, documentationFormat: %w[markdown plaintext], resolveSupport: {properties: %w[documentation detail additionalTextEdits]}}},
|
|
282
|
+
hover: {contentFormat: %w[markdown plaintext]},
|
|
283
|
+
signatureHelp: {signatureInformation: {documentationFormat: %w[markdown plaintext], parameterInformation: {labelOffsetSupport: true}}},
|
|
284
|
+
documentSymbol: {hierarchicalDocumentSymbolSupport: true},
|
|
285
|
+
codeAction: {codeActionLiteralSupport: {codeActionKind: {valueSet: %w[quickfix refactor refactor.extract refactor.inline refactor.rewrite source source.organizeImports]}}, resolveSupport: {properties: ["edit"]}},
|
|
286
|
+
publishDiagnostics: {relatedInformation: true, versionSupport: true},
|
|
287
|
+
diagnostic: {dynamicRegistration: false, relatedDocumentSupport: false},
|
|
288
|
+
inlayHint: {dynamicRegistration: false},
|
|
289
|
+
codeLens: {dynamicRegistration: false},
|
|
290
|
+
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}
|
|
291
|
+
},
|
|
292
|
+
workspace: {applyEdit: true, configuration: true, workspaceFolders: true, workspaceEdit: {documentChanges: true, resourceOperations: %w[create rename delete], failureHandling: "abort"}}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def validate_capabilities
|
|
298
|
+
mode = sync_mode
|
|
299
|
+
raise Error, "invalid text document synchronization mode" unless mode.nil? || [0, 1, 2].include?(mode)
|
|
300
|
+
|
|
301
|
+
@position_encoding = @capabilities.fetch("positionEncoding", "utf-16")
|
|
302
|
+
unless @position_encoding == "utf-16"
|
|
303
|
+
raise Error, "server selected unadvertised position encoding #{@position_encoding}"
|
|
304
|
+
end
|
|
305
|
+
semantic = @capabilities["semanticTokensProvider"]
|
|
306
|
+
raise Error, "missing semantic token legend" if semantic && (!semantic.is_a?(Hash) || !semantic["legend"].is_a?(Hash))
|
|
307
|
+
|
|
308
|
+
Protocol.semantic_tokens([], legend: semantic["legend"]) if semantic.is_a?(Hash)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def validate_document(document)
|
|
312
|
+
raise Error, "expected a Document" unless document.is_a?(Document)
|
|
313
|
+
valid_uri(document.uri)
|
|
314
|
+
unless document.language_id.is_a?(String) && !document.language_id.empty?
|
|
315
|
+
raise Error, "language id must be a nonempty String"
|
|
316
|
+
end
|
|
317
|
+
raise Error, "document version must be an unsigned integer" unless Protocol.uint?(document.version)
|
|
318
|
+
unless document.text.is_a?(String) && document.text.valid_encoding?
|
|
319
|
+
raise Error, "document text must be valid UTF-8"
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def validate_change(change)
|
|
324
|
+
raise Error, "expected a ContentChange" unless change.is_a?(ContentChange)
|
|
325
|
+
raise Error, "change text must be valid UTF-8" unless change.text.is_a?(String) && change.text.valid_encoding?
|
|
326
|
+
|
|
327
|
+
Protocol.range_value(change.range) if change.range
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def valid_uri(uri)
|
|
331
|
+
raise Error, "URI must be a nonempty String" unless uri.is_a?(String) && !uri.empty? && !uri.include?("\0") && uri.valid_encoding?
|
|
332
|
+
|
|
333
|
+
uri
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def request_at(method, uri, position, params = {})
|
|
337
|
+
core = {textDocument: {uri: valid_uri(uri)}, position: Protocol.position_hash(position)}
|
|
338
|
+
request("textDocument/#{method}", params.merge(core))
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def request_document(method, uri, **params)
|
|
342
|
+
request("textDocument/#{method}", params.merge(textDocument: {uri: valid_uri(uri)}))
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def resolve(method, value)
|
|
346
|
+
raise Error, "resolve value must be an object" unless value.is_a?(Hash)
|
|
347
|
+
|
|
348
|
+
request(method, value)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def notify_open(document)
|
|
352
|
+
notify("textDocument/didOpen", {textDocument: {uri: document.uri, languageId: document.language_id, version: document.version, text: document.text}})
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def sync_mode
|
|
356
|
+
sync = @capabilities["textDocumentSync"]
|
|
357
|
+
sync.is_a?(Hash) ? sync.fetch("change", 0) : sync
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def open_close?
|
|
361
|
+
sync = @capabilities["textDocumentSync"]
|
|
362
|
+
sync.is_a?(Hash) ? sync["openClose"] : sync.is_a?(Integer) && sync.positive?
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def cancel(id)
|
|
366
|
+
@lock.synchronize { @pending.delete(id) }
|
|
367
|
+
notify("$/cancelRequest", {id: id})
|
|
368
|
+
rescue Error
|
|
369
|
+
nil
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def fail_pending(error)
|
|
373
|
+
pending = @lock.synchronize do
|
|
374
|
+
values = @pending.values
|
|
375
|
+
@pending.clear
|
|
376
|
+
values
|
|
377
|
+
end
|
|
378
|
+
pending.each { |future| future.fulfill(error: error) }
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def report_error(error)
|
|
382
|
+
bounded = Error.new("#{error.class}: #{error.message}".scrub.byteslice(0, 2048).scrub(""))
|
|
383
|
+
@lock.synchronize do
|
|
384
|
+
@errors << bounded
|
|
385
|
+
@errors.shift if @errors.length > 200
|
|
386
|
+
end
|
|
387
|
+
@dispatch.call do
|
|
388
|
+
begin
|
|
389
|
+
@handlers["error"]&.call(bounded)
|
|
390
|
+
rescue StandardError
|
|
391
|
+
nil
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
rescue StandardError
|
|
395
|
+
nil
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def receive(message, error, epoch = @epoch)
|
|
399
|
+
return unless epoch == @epoch
|
|
400
|
+
|
|
401
|
+
if error
|
|
402
|
+
receive_error(error)
|
|
403
|
+
elsif message.key?("id") && !message.key?("method")
|
|
404
|
+
future = @lock.synchronize { @pending.delete(message["id"]) }
|
|
405
|
+
future&.fulfill(message["result"], error: message["error"] && ServerError.new(message["error"]))
|
|
406
|
+
elsif message["method"]
|
|
407
|
+
receive_call(message, epoch)
|
|
408
|
+
end
|
|
409
|
+
rescue StandardError => failure
|
|
410
|
+
report_error(failure)
|
|
411
|
+
if message&.key?("id") && message.key?("method")
|
|
412
|
+
reply(message["id"], epoch, error: {code: -32603, message: "client dispatch failed"})
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def receive_error(error)
|
|
417
|
+
running = @state == :running
|
|
418
|
+
@state = :failed
|
|
419
|
+
fail_pending(Error.new(error.message))
|
|
420
|
+
report_error(error)
|
|
421
|
+
restart_server if running && @restart && !@closing && @restarts < 3
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def receive_call(message, epoch)
|
|
425
|
+
method = message["method"]
|
|
426
|
+
params = message.fetch("params", {})
|
|
427
|
+
@dispatch.call do
|
|
428
|
+
next unless epoch == @epoch && !@closing
|
|
429
|
+
|
|
430
|
+
begin
|
|
431
|
+
receive_diagnostics(params) if method == "textDocument/publishDiagnostics"
|
|
432
|
+
known = @handlers.key?(method)
|
|
433
|
+
result = @handlers[method]&.call(params)
|
|
434
|
+
next unless message.key?("id")
|
|
435
|
+
|
|
436
|
+
unless known
|
|
437
|
+
known, result = built_in_request(method, params)
|
|
438
|
+
end
|
|
439
|
+
respond_to_server(message["id"], epoch, known, result, method)
|
|
440
|
+
rescue StandardError => failure
|
|
441
|
+
report_error(failure)
|
|
442
|
+
reply(message["id"], epoch, error: {code: -32603, message: "client request handler failed"}) if message.key?("id")
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def receive_diagnostics(params)
|
|
448
|
+
valid = params.is_a?(Hash) && params["uri"].is_a?(String) && params["diagnostics"].is_a?(Array)
|
|
449
|
+
raise Error, "invalid diagnostics notification" unless valid
|
|
450
|
+
|
|
451
|
+
document = @documents[params["uri"]]
|
|
452
|
+
version = params["version"]
|
|
453
|
+
raise Error, "invalid diagnostic version" if !version.nil? && !version.is_a?(Integer)
|
|
454
|
+
return if document && version && version < document.version
|
|
455
|
+
|
|
456
|
+
@diagnostics[params["uri"]] = Protocol.diagnostics(params["diagnostics"])
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def built_in_request(method, params)
|
|
460
|
+
case method
|
|
461
|
+
when "workspace/configuration"
|
|
462
|
+
items = params.fetch("items")
|
|
463
|
+
raise Error, "invalid configuration request" unless items.is_a?(Array)
|
|
464
|
+
|
|
465
|
+
[true, items.map do |item|
|
|
466
|
+
section = item["section"]
|
|
467
|
+
section ? @configuration.dig(*section.split(".")) : @configuration
|
|
468
|
+
end]
|
|
469
|
+
when "workspace/workspaceFolders"
|
|
470
|
+
[true, [{uri: Protocol.uri(@root), name: File.basename(@root)}]]
|
|
471
|
+
when "window/workDoneProgress/create", "workspace/semanticTokens/refresh", "workspace/inlayHint/refresh", "workspace/codeLens/refresh", "workspace/diagnostic/refresh"
|
|
472
|
+
@semantic.clear if method == "workspace/semanticTokens/refresh"
|
|
473
|
+
[true, nil]
|
|
474
|
+
else
|
|
475
|
+
[false, nil]
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def respond_to_server(id, epoch, known, result, method)
|
|
480
|
+
unless known
|
|
481
|
+
reply(id, epoch, error: {code: -32601, message: "unsupported client request #{method}"})
|
|
482
|
+
return
|
|
483
|
+
end
|
|
484
|
+
if result.is_a?(Future)
|
|
485
|
+
result.then do |value, failure|
|
|
486
|
+
if failure
|
|
487
|
+
reply(id, epoch, error: {code: -32603, message: failure.message.byteslice(0, 2048).scrub})
|
|
488
|
+
else
|
|
489
|
+
reply(id, epoch, value: value)
|
|
490
|
+
end
|
|
491
|
+
end
|
|
492
|
+
else
|
|
493
|
+
reply(id, epoch, value: result)
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def reply(id, epoch, value: nil, error: nil)
|
|
498
|
+
return unless epoch == @epoch && !@closing
|
|
499
|
+
|
|
500
|
+
response = {jsonrpc: "2.0", id: id}
|
|
501
|
+
error ? response[:error] = error : response[:result] = value
|
|
502
|
+
@transport.write(response)
|
|
503
|
+
rescue StandardError => failure
|
|
504
|
+
report_error(failure)
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def restart_server
|
|
508
|
+
return if @restart_thread&.alive?
|
|
509
|
+
|
|
510
|
+
previous = @transport
|
|
511
|
+
@restart_thread = Thread.new do
|
|
512
|
+
previous.close
|
|
513
|
+
until @closing || @restarts >= 3
|
|
514
|
+
@restarts += 1
|
|
515
|
+
sleep(0.2 * @restarts)
|
|
516
|
+
break if @closing
|
|
517
|
+
|
|
518
|
+
begin
|
|
519
|
+
start
|
|
520
|
+
@documents.values.dup.each { |document| notify_open(document) if open_close? }
|
|
521
|
+
break
|
|
522
|
+
rescue StandardError => failure
|
|
523
|
+
report_error(failure)
|
|
524
|
+
end
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
end
|
|
528
|
+
end
|
|
529
|
+
end
|