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
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sadr
|
|
4
|
+
class DocumentIndex
|
|
5
|
+
Point = Struct.new(:row, :column)
|
|
6
|
+
BREAK = /(?:\r\n|[\r\n\u2028\u2029])\z/
|
|
7
|
+
|
|
8
|
+
def initialize(text)
|
|
9
|
+
@text = text
|
|
10
|
+
@line_starts = [0]
|
|
11
|
+
offset = 0
|
|
12
|
+
previous_cr = false
|
|
13
|
+
text.each_char do |character|
|
|
14
|
+
offset += character.bytesize
|
|
15
|
+
case character
|
|
16
|
+
when "\r"
|
|
17
|
+
@line_starts << offset
|
|
18
|
+
when "\n"
|
|
19
|
+
previous_cr ? @line_starts[-1] = offset : @line_starts << offset
|
|
20
|
+
when "\u2028", "\u2029"
|
|
21
|
+
@line_starts << offset
|
|
22
|
+
end
|
|
23
|
+
previous_cr = character == "\r"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def line_start(row)
|
|
28
|
+
@line_starts.fetch(row)
|
|
29
|
+
rescue IndexError
|
|
30
|
+
raise RangeError, "line out of bounds"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def line(row)
|
|
34
|
+
start = line_start(row)
|
|
35
|
+
finish = @line_starts.fetch(row + 1, @text.bytesize)
|
|
36
|
+
@text.byteslice(start, finish - start).sub(BREAK, "")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def utf16_offset_at(byte_offset)
|
|
40
|
+
prefix = prefix_at(byte_offset)
|
|
41
|
+
prefix.each_codepoint.sum { |codepoint| codepoint > 0xffff ? 2 : 1 }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def offset_at_utf16(utf16_offset)
|
|
45
|
+
raise RangeError, "UTF-16 offset out of bounds" unless utf16_offset.is_a?(Integer) && utf16_offset >= 0
|
|
46
|
+
|
|
47
|
+
units = 0
|
|
48
|
+
bytes = 0
|
|
49
|
+
@text.each_codepoint do |codepoint|
|
|
50
|
+
return bytes if units == utf16_offset
|
|
51
|
+
|
|
52
|
+
units += codepoint > 0xffff ? 2 : 1
|
|
53
|
+
bytes += codepoint.chr(Encoding::UTF_8).bytesize
|
|
54
|
+
raise RangeError, "UTF-16 offset splits a surrogate pair" if units > utf16_offset
|
|
55
|
+
end
|
|
56
|
+
return bytes if units == utf16_offset
|
|
57
|
+
|
|
58
|
+
raise RangeError, "UTF-16 offset out of bounds"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def utf16_point_at(byte_offset)
|
|
62
|
+
prefix_at(byte_offset)
|
|
63
|
+
row = @line_starts.bsearch_index { |start| start > byte_offset }
|
|
64
|
+
row = row ? row - 1 : @line_starts.length - 1
|
|
65
|
+
Point.new(row, utf16_offset_at(byte_offset) - utf16_offset_at(line_start(row)))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def prefix_at(byte_offset)
|
|
71
|
+
raise RangeError, "byte offset out of bounds" unless byte_offset.is_a?(Integer) && byte_offset.between?(0, @text.bytesize)
|
|
72
|
+
|
|
73
|
+
prefix = @text.byteslice(0, byte_offset)
|
|
74
|
+
raise RangeError, "byte offset splits a character" unless prefix.valid_encoding?
|
|
75
|
+
|
|
76
|
+
prefix
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
private_constant :DocumentIndex
|
|
80
|
+
end
|
data/lib/sadr/future.rb
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sadr
|
|
4
|
+
class Future
|
|
5
|
+
class Subscription
|
|
6
|
+
def initialize(&detach) = @detach = detach
|
|
7
|
+
|
|
8
|
+
def detach
|
|
9
|
+
callback, @detach = @detach, nil
|
|
10
|
+
callback&.call
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
attr_reader :id, :callback_errors
|
|
15
|
+
|
|
16
|
+
def initialize(id, on_error: nil, &cancel)
|
|
17
|
+
@id = id
|
|
18
|
+
@cancel_callback = cancel
|
|
19
|
+
@on_error = on_error
|
|
20
|
+
@lock = Mutex.new
|
|
21
|
+
@ready = ConditionVariable.new
|
|
22
|
+
@callbacks = []
|
|
23
|
+
@callback_errors = []
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def fulfill(value = nil, error: nil)
|
|
27
|
+
callbacks = @lock.synchronize do
|
|
28
|
+
return if @done
|
|
29
|
+
|
|
30
|
+
@value = value
|
|
31
|
+
@error = error
|
|
32
|
+
@done = true
|
|
33
|
+
@ready.broadcast
|
|
34
|
+
saved, @callbacks = @callbacks, []
|
|
35
|
+
saved
|
|
36
|
+
end
|
|
37
|
+
callbacks.each { |callback| invoke(callback) }
|
|
38
|
+
self
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def then(&callback)
|
|
42
|
+
raise ArgumentError, "callback required" unless callback
|
|
43
|
+
|
|
44
|
+
on_complete { callback.call(@value, @error) }
|
|
45
|
+
self
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def done? = @lock.synchronize { !!@done }
|
|
49
|
+
|
|
50
|
+
def on_complete(&callback)
|
|
51
|
+
raise ArgumentError, "callback required" unless callback
|
|
52
|
+
|
|
53
|
+
ready = @lock.synchronize do
|
|
54
|
+
@callbacks << callback unless @done
|
|
55
|
+
@done
|
|
56
|
+
end
|
|
57
|
+
invoke(callback) if ready
|
|
58
|
+
Subscription.new { @lock.synchronize { @callbacks.delete(callback) } }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def await(timeout: 10)
|
|
62
|
+
valid = timeout.nil? || (timeout.is_a?(Numeric) && timeout.finite? && timeout >= 0)
|
|
63
|
+
raise ArgumentError, "timeout must be finite and nonnegative" unless valid
|
|
64
|
+
|
|
65
|
+
if defined?(Zaniah::TaskExecutor) && Zaniah::TaskExecutor.current && !done?
|
|
66
|
+
return Zaniah::TaskExecutor.current.await(self, timeout: timeout)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
deadline = timeout && Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
70
|
+
@lock.synchronize do
|
|
71
|
+
until @done
|
|
72
|
+
remaining = deadline && deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
73
|
+
raise Timeout, "LSP request #{@id} timed out" if remaining && remaining <= 0
|
|
74
|
+
|
|
75
|
+
@ready.wait(@lock, remaining)
|
|
76
|
+
end
|
|
77
|
+
raise @error if @error
|
|
78
|
+
|
|
79
|
+
@value
|
|
80
|
+
end
|
|
81
|
+
rescue StandardError => error
|
|
82
|
+
if error.is_a?(Timeout) || (defined?(Zaniah::Task::Timeout) && error.is_a?(Zaniah::Task::Timeout))
|
|
83
|
+
cancel
|
|
84
|
+
raise Timeout, "LSP request #{@id} timed out"
|
|
85
|
+
end
|
|
86
|
+
raise
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def cancel
|
|
90
|
+
callback = @lock.synchronize do
|
|
91
|
+
return false if @done || @cancelling
|
|
92
|
+
|
|
93
|
+
@cancelling = true
|
|
94
|
+
@cancel_callback
|
|
95
|
+
end
|
|
96
|
+
invoke(-> { callback.call(@id) }) if callback
|
|
97
|
+
fulfill(error: Error.new("request cancelled"))
|
|
98
|
+
true
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def invoke(callback)
|
|
104
|
+
callback.call
|
|
105
|
+
rescue StandardError => error
|
|
106
|
+
bounded = Error.new("#{error.class}: #{error.message}".scrub.byteslice(0, 2048).scrub(""))
|
|
107
|
+
@lock.synchronize do
|
|
108
|
+
@callback_errors << bounded
|
|
109
|
+
@callback_errors.shift if @callback_errors.length > 32
|
|
110
|
+
end
|
|
111
|
+
begin
|
|
112
|
+
@on_error&.call(bounded)
|
|
113
|
+
rescue StandardError
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sadr
|
|
4
|
+
module Protocol
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def uri(path)
|
|
8
|
+
raise Error, "path must be a String" unless path.is_a?(String) && !path.include?("\0")
|
|
9
|
+
|
|
10
|
+
absolute = File.expand_path(path).tr("\\", "/")
|
|
11
|
+
absolute = "/#{absolute}" if absolute.match?(/\A[A-Za-z]:/)
|
|
12
|
+
"file://" + URI::RFC2396_PARSER.escape(absolute, /[^a-zA-Z0-9\-._~\/:]/)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def path(uri)
|
|
16
|
+
parsed = URI.parse(uri)
|
|
17
|
+
valid = parsed.scheme == "file" && [nil, "", "localhost"].include?(parsed.host) &&
|
|
18
|
+
parsed.query.nil? && parsed.fragment.nil? && parsed.path&.start_with?("/")
|
|
19
|
+
raise Error, "expected local file URI" unless valid
|
|
20
|
+
|
|
21
|
+
value = URI::RFC2396_PARSER.unescape(parsed.path)
|
|
22
|
+
raise Error, "invalid file URI path" if value.include?("\0") || !value.valid_encoding?
|
|
23
|
+
|
|
24
|
+
RUBY_PLATFORM.match?(/mswin|mingw/) ? value.sub(%r{\A/([A-Za-z]:/)}, '\\1') : value
|
|
25
|
+
rescue URI::InvalidURIError => error
|
|
26
|
+
raise Error, error.message
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def position(index, offset)
|
|
30
|
+
point = index.utf16_point_at(offset)
|
|
31
|
+
Position.new(line: point.row, character: point.column)
|
|
32
|
+
rescue NoMethodError => error
|
|
33
|
+
raise Error, "invalid text index: #{error.message}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def offset(index, position)
|
|
37
|
+
point = position_value(position)
|
|
38
|
+
start = index.line_start(point.line)
|
|
39
|
+
finish = start + index.line(point.line).bytesize
|
|
40
|
+
units = index.utf16_offset_at(finish) - index.utf16_offset_at(start)
|
|
41
|
+
index.offset_at_utf16(index.utf16_offset_at(start) + [point.character, units].min)
|
|
42
|
+
rescue NoMethodError => error
|
|
43
|
+
raise Error, "invalid text index: #{error.message}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def range(index, byte_range)
|
|
47
|
+
raise Error, "invalid byte range" unless byte_range.is_a?(Range) && byte_range.begin.is_a?(Integer) && byte_range.end.is_a?(Integer)
|
|
48
|
+
|
|
49
|
+
finish = byte_range.end + (byte_range.exclude_end? ? 0 : 1)
|
|
50
|
+
Range_.new(start: position(index, byte_range.begin), end: position(index, finish))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def text_edits(index, edits)
|
|
54
|
+
raise Error, "invalid LSP text edits" unless edits.is_a?(Array)
|
|
55
|
+
|
|
56
|
+
edits.map do |edit|
|
|
57
|
+
valid = edit.is_a?(Hash) && fetch(edit, "range").is_a?(Hash) && fetch(edit, "newText").is_a?(String)
|
|
58
|
+
raise Error, "invalid LSP text edit" unless valid && fetch(edit, "newText").valid_encoding?
|
|
59
|
+
|
|
60
|
+
value = fetch(edit, "range")
|
|
61
|
+
first = offset(index, fetch(value, "start"))
|
|
62
|
+
last = offset(index, fetch(value, "end"))
|
|
63
|
+
raise Error, "invalid LSP text edit range" if last < first
|
|
64
|
+
|
|
65
|
+
[first...last, fetch(edit, "newText")]
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def semantic_delta(data, edits)
|
|
70
|
+
raise Error, "invalid semantic token delta" unless data.is_a?(Array) && edits.is_a?(Array)
|
|
71
|
+
|
|
72
|
+
edits.each do |edit|
|
|
73
|
+
valid = edit.is_a?(Hash) && uint?(fetch(edit, "start")) && uint?(fetch(edit, "deleteCount"))
|
|
74
|
+
inserted = fetch(edit, "data", [])
|
|
75
|
+
raise Error, "invalid semantic token delta" unless valid && inserted.is_a?(Array)
|
|
76
|
+
end
|
|
77
|
+
sorted = edits.sort_by { |edit| fetch(edit, "start") }
|
|
78
|
+
previous_end = 0
|
|
79
|
+
sorted.each do |edit|
|
|
80
|
+
start = fetch(edit, "start")
|
|
81
|
+
count = fetch(edit, "deleteCount")
|
|
82
|
+
raise Error, "invalid semantic token delta" if start < previous_end || start + count > data.length
|
|
83
|
+
|
|
84
|
+
previous_end = start + count
|
|
85
|
+
end
|
|
86
|
+
output = data.dup
|
|
87
|
+
sorted.reverse_each do |edit|
|
|
88
|
+
output[fetch(edit, "start"), fetch(edit, "deleteCount")] = fetch(edit, "data", [])
|
|
89
|
+
end
|
|
90
|
+
scan_semantic(output, nil, false)
|
|
91
|
+
output
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def diagnostics(values)
|
|
95
|
+
valid = values.is_a?(Array) && values.all? do |value|
|
|
96
|
+
next false unless value.is_a?(Hash) && fetch(value, "message").is_a?(String) && fetch(value, "range").is_a?(Hash)
|
|
97
|
+
|
|
98
|
+
range = fetch(value, "range")
|
|
99
|
+
points = %w[start end].map { |key| fetch(range, key, nil) }
|
|
100
|
+
severity = fetch(value, "severity", nil)
|
|
101
|
+
has_severity = value.key?("severity") || value.key?(:severity)
|
|
102
|
+
points.all? { |point| valid_position?(point) } &&
|
|
103
|
+
(position_tuple(points[0]) <=> position_tuple(points[1])) <= 0 &&
|
|
104
|
+
(!has_severity || (severity.is_a?(Integer) && severity.between?(1, 4)))
|
|
105
|
+
end
|
|
106
|
+
raise Error, "invalid LSP diagnostics" unless valid
|
|
107
|
+
|
|
108
|
+
values
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def semantic_tokens(data, legend: nil)
|
|
112
|
+
scan_semantic(data, legend, true)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def scan_semantic(data, legend, collect)
|
|
116
|
+
raise Error, "invalid semantic token tuple count" unless data.is_a?(Array) && (data.length % 5).zero?
|
|
117
|
+
if legend
|
|
118
|
+
valid = legend.is_a?(Hash) && %w[tokenTypes tokenModifiers].all? do |key|
|
|
119
|
+
value = fetch(legend, key)
|
|
120
|
+
value.is_a?(Array) && value.all? { |name| name.is_a?(String) }
|
|
121
|
+
end
|
|
122
|
+
raise Error, "invalid semantic token legend" unless valid
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
unless data.all? { |value| value.is_a?(Integer) && value.between?(0, 0x7fffffff) }
|
|
126
|
+
raise Error, "invalid semantic token value"
|
|
127
|
+
end
|
|
128
|
+
row = 0
|
|
129
|
+
column = 0
|
|
130
|
+
tokens = collect ? Array.new(data.length / 5) : nil
|
|
131
|
+
index = 0
|
|
132
|
+
while index < data.length
|
|
133
|
+
delta_row = data[index]
|
|
134
|
+
delta_column = data[index + 1]
|
|
135
|
+
length = data[index + 2]
|
|
136
|
+
type = data[index + 3]
|
|
137
|
+
modifiers = data[index + 4]
|
|
138
|
+
raise Error, "invalid semantic token value" unless length.positive?
|
|
139
|
+
if legend
|
|
140
|
+
types = fetch(legend, "tokenTypes")
|
|
141
|
+
token_modifiers = fetch(legend, "tokenModifiers")
|
|
142
|
+
raise Error, "semantic token exceeds legend" if type >= types.length || modifiers.bit_length > token_modifiers.length
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
row += delta_row
|
|
146
|
+
column = delta_row.zero? ? column + delta_column : delta_column
|
|
147
|
+
raise Error, "semantic token position overflow" unless uint?(row) && uint?(column + length)
|
|
148
|
+
|
|
149
|
+
tokens[index / 5] = Token.new(line: row, character: column, length: length, type: type, modifiers: modifiers) if collect
|
|
150
|
+
index += 5
|
|
151
|
+
end
|
|
152
|
+
tokens
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def uint?(value)
|
|
156
|
+
value.is_a?(Integer) && value.between?(0, 0x7fffffff)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def position_value(value)
|
|
160
|
+
return value if value.is_a?(Position) && uint?(value.line) && uint?(value.character)
|
|
161
|
+
return Position.new(line: fetch(value, "line"), character: fetch(value, "character")) if valid_position?(value)
|
|
162
|
+
|
|
163
|
+
raise Error, "invalid LSP position"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def range_value(value)
|
|
167
|
+
if value.is_a?(Range_)
|
|
168
|
+
valid = (position_tuple(value.start) <=> position_tuple(value.end)) <= 0
|
|
169
|
+
return Range_.new(start: position_value(value.start), end: position_value(value.end)) if valid
|
|
170
|
+
elsif value.is_a?(Hash)
|
|
171
|
+
first = position_value(fetch(value, "start"))
|
|
172
|
+
last = position_value(fetch(value, "end"))
|
|
173
|
+
return Range_.new(start: first, end: last) if (position_tuple(first) <=> position_tuple(last)) <= 0
|
|
174
|
+
end
|
|
175
|
+
raise Error, "invalid LSP range"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def position_hash(value)
|
|
179
|
+
point = position_value(value)
|
|
180
|
+
{line: point.line, character: point.character}
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def range_hash(value)
|
|
184
|
+
value = range_value(value)
|
|
185
|
+
{start: position_hash(value.start), end: position_hash(value.end)}
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def valid_position?(value)
|
|
189
|
+
value.is_a?(Hash) && uint?(fetch(value, "line")) && uint?(fetch(value, "character"))
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def position_tuple(value)
|
|
193
|
+
point = value.is_a?(Position) ? value : position_value(value)
|
|
194
|
+
[point.line, point.character]
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def fetch(hash, key, default = :__missing__)
|
|
198
|
+
return hash[key] if hash.key?(key)
|
|
199
|
+
symbol = key.to_sym
|
|
200
|
+
return hash[symbol] if hash.key?(symbol)
|
|
201
|
+
return default unless default == :__missing__
|
|
202
|
+
|
|
203
|
+
raise KeyError, "key not found: #{key}"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
private_class_method :scan_semantic, :valid_position?, :position_tuple, :fetch
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sadr
|
|
4
|
+
class Transport
|
|
5
|
+
MAX_MESSAGE = 32 << 20
|
|
6
|
+
|
|
7
|
+
attr_reader :stderr_lines, :pid
|
|
8
|
+
|
|
9
|
+
def initialize(command, cwd: nil, env: {}, &receive)
|
|
10
|
+
valid = command.is_a?(Array) && !command.empty? && command.all? do |part|
|
|
11
|
+
part.is_a?(String) && !part.include?("\0")
|
|
12
|
+
end
|
|
13
|
+
raise ArgumentError, "command must be a nonempty argument array" unless valid
|
|
14
|
+
raise ArgumentError, "receiver required" unless receive
|
|
15
|
+
|
|
16
|
+
options = cwd ? {chdir: cwd} : {}
|
|
17
|
+
@stdin, @stdout, @stderr, @process = Open3.popen3(env, *command, **options)
|
|
18
|
+
@stdin.binmode
|
|
19
|
+
@stdout.binmode
|
|
20
|
+
@pid = @process.pid
|
|
21
|
+
@write_lock = Mutex.new
|
|
22
|
+
@stderr_lines = []
|
|
23
|
+
@reader = Thread.new { read(receive) }
|
|
24
|
+
@logger = Thread.new { read_stderr }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.read_message(io)
|
|
28
|
+
headers = {}
|
|
29
|
+
count = 0
|
|
30
|
+
loop do
|
|
31
|
+
line = io.gets("\r\n", 8193)
|
|
32
|
+
return nil if line.nil? && headers.empty?
|
|
33
|
+
raise Error, "truncated or oversized LSP header" unless line && line.end_with?("\r\n") && line.bytesize <= 8192
|
|
34
|
+
break if line == "\r\n"
|
|
35
|
+
|
|
36
|
+
count += line.bytesize
|
|
37
|
+
raise Error, "oversized LSP headers" if count > 16_384
|
|
38
|
+
raise Error, "non-ASCII LSP header" unless line.ascii_only?
|
|
39
|
+
|
|
40
|
+
key, value = line.strip.split(":", 2)
|
|
41
|
+
raise Error, "invalid LSP header" unless value && key.match?(/\A[A-Za-z][A-Za-z0-9-]*\z/)
|
|
42
|
+
|
|
43
|
+
key = key.downcase
|
|
44
|
+
raise Error, "duplicate LSP header" if headers.key?(key)
|
|
45
|
+
|
|
46
|
+
headers[key] = value.strip
|
|
47
|
+
end
|
|
48
|
+
raw_length = headers["content-length"]
|
|
49
|
+
raise Error, "missing or invalid Content-Length" unless raw_length&.match?(/\A\d+\z/)
|
|
50
|
+
|
|
51
|
+
length = Integer(raw_length, 10)
|
|
52
|
+
raise Error, "oversized LSP message" unless length.between?(1, MAX_MESSAGE)
|
|
53
|
+
|
|
54
|
+
charset = headers["content-type"]&.match(/charset\s*=\s*"?([^;"\s]+)/i)&.[](1)
|
|
55
|
+
raise Error, "unsupported LSP character encoding" if charset && !%w[utf-8 utf8].include?(charset.downcase)
|
|
56
|
+
|
|
57
|
+
body = io.read(length)
|
|
58
|
+
raise Error, "truncated LSP body" unless body && body.bytesize == length
|
|
59
|
+
|
|
60
|
+
body.force_encoding(Encoding::UTF_8)
|
|
61
|
+
raise Error, "invalid LSP UTF-8 body" unless body.valid_encoding?
|
|
62
|
+
|
|
63
|
+
validate_message(JSON.parse(body))
|
|
64
|
+
rescue JSON::ParserError => error
|
|
65
|
+
raise Error, "invalid LSP JSON: #{error.message.byteslice(0, 256)}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.validate_message(message)
|
|
69
|
+
raise Error, "invalid JSON-RPC message" unless message.is_a?(Hash) && message["jsonrpc"] == "2.0"
|
|
70
|
+
if message.key?("method")
|
|
71
|
+
raise Error, "invalid JSON-RPC method" unless message["method"].is_a?(String) && !message["method"].empty?
|
|
72
|
+
invalid_params = message.key?("params") && !message["params"].is_a?(Hash) && !message["params"].is_a?(Array)
|
|
73
|
+
raise Error, "invalid JSON-RPC parameters" if invalid_params
|
|
74
|
+
raise Error, "request contains a response" if message.key?("result") || message.key?("error")
|
|
75
|
+
else
|
|
76
|
+
valid_response = message.key?("id") && (message.key?("result") ^ message.key?("error"))
|
|
77
|
+
raise Error, "invalid JSON-RPC response" unless valid_response
|
|
78
|
+
if message.key?("error")
|
|
79
|
+
error = message["error"]
|
|
80
|
+
valid_error = error.is_a?(Hash) && error["code"].is_a?(Integer) && error["message"].is_a?(String)
|
|
81
|
+
raise Error, "invalid JSON-RPC error" unless valid_error
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
id = message["id"]
|
|
85
|
+
valid_id = id.is_a?(Integer) || id.is_a?(String) || (id.nil? && !message.key?("method"))
|
|
86
|
+
raise Error, "invalid JSON-RPC id" if message.key?("id") && !valid_id
|
|
87
|
+
|
|
88
|
+
message
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def write(message)
|
|
92
|
+
raise Error, "expected JSON-RPC object" unless message.is_a?(Hash)
|
|
93
|
+
|
|
94
|
+
normalized = message.transform_keys(&:to_s)
|
|
95
|
+
normalized["error"] = normalized["error"].transform_keys(&:to_s) if normalized["error"].is_a?(Hash)
|
|
96
|
+
self.class.validate_message(normalized)
|
|
97
|
+
body = JSON.generate(message).b
|
|
98
|
+
raise Error, "oversized LSP message" unless body.bytesize.between?(1, MAX_MESSAGE)
|
|
99
|
+
|
|
100
|
+
@write_lock.synchronize do
|
|
101
|
+
@stdin.write("Content-Length: #{body.bytesize}\r\n\r\n")
|
|
102
|
+
@stdin.write(body)
|
|
103
|
+
@stdin.flush
|
|
104
|
+
end
|
|
105
|
+
rescue IOError, Errno::EPIPE => error
|
|
106
|
+
raise Error, "language server write failed: #{error.message}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def alive? = @process.alive?
|
|
110
|
+
|
|
111
|
+
def close
|
|
112
|
+
return if @closing
|
|
113
|
+
|
|
114
|
+
@closing = true
|
|
115
|
+
@stdin.close unless @stdin.closed?
|
|
116
|
+
unless @process.join(1)
|
|
117
|
+
begin
|
|
118
|
+
Process.kill("TERM", @pid)
|
|
119
|
+
rescue Errno::ESRCH
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
unless @process.join(1)
|
|
123
|
+
begin
|
|
124
|
+
Process.kill("KILL", @pid)
|
|
125
|
+
rescue Errno::ESRCH
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
@process.join
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
[@stdout, @stderr].each { |io| io.close unless io.closed? }
|
|
132
|
+
[@reader, @logger].each do |thread|
|
|
133
|
+
next if thread == Thread.current
|
|
134
|
+
|
|
135
|
+
thread.kill unless thread.join(1)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
def read(receive)
|
|
142
|
+
loop do
|
|
143
|
+
message = self.class.read_message(@stdout)
|
|
144
|
+
break unless message
|
|
145
|
+
|
|
146
|
+
receive.call(message, nil)
|
|
147
|
+
end
|
|
148
|
+
receive.call(nil, Error.new("language server closed stdout")) unless @closing
|
|
149
|
+
rescue StandardError => error
|
|
150
|
+
begin
|
|
151
|
+
receive.call(nil, error) unless @closing
|
|
152
|
+
rescue StandardError
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def read_stderr
|
|
158
|
+
while (line = @stderr.gets("\n", 8192))
|
|
159
|
+
@stderr_lines << line.scrub.byteslice(0, 8192).scrub("")
|
|
160
|
+
@stderr_lines.shift if @stderr_lines.length > 200
|
|
161
|
+
end
|
|
162
|
+
rescue IOError
|
|
163
|
+
nil
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
data/lib/sadr/version.rb
ADDED
data/lib/sadr.rb
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "thread"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
require_relative "sadr/version"
|
|
9
|
+
|
|
10
|
+
module Sadr
|
|
11
|
+
class Error < StandardError; end
|
|
12
|
+
class Timeout < Error; end
|
|
13
|
+
|
|
14
|
+
class ServerError < Error
|
|
15
|
+
attr_reader :code, :data
|
|
16
|
+
|
|
17
|
+
def initialize(error)
|
|
18
|
+
@code = error["code"]
|
|
19
|
+
@data = error["data"]
|
|
20
|
+
super(error["message"])
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
module Value
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
def define(*members)
|
|
28
|
+
return Data.define(*members) if defined?(Data)
|
|
29
|
+
|
|
30
|
+
Struct.new(*members, keyword_init: true) do
|
|
31
|
+
def initialize(**values)
|
|
32
|
+
super
|
|
33
|
+
freeze
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
Position = Value.define(:line, :character)
|
|
40
|
+
Range_ = Value.define(:start, :end)
|
|
41
|
+
ContentChange = Value.define(:range, :text)
|
|
42
|
+
Document = Value.define(:uri, :language_id, :version, :text)
|
|
43
|
+
Token = Value.define(:line, :character, :length, :type, :modifiers)
|
|
44
|
+
private_constant :Value
|
|
45
|
+
|
|
46
|
+
# Text indexes are accepted by protocol conversion methods through duck typing.
|
|
47
|
+
# Required methods are documented in sig/sadr.rbs.
|
|
48
|
+
module TextIndex; end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
require_relative "sadr/protocol"
|
|
52
|
+
require_relative "sadr/future"
|
|
53
|
+
require_relative "sadr/transport"
|
|
54
|
+
require_relative "sadr/document_sync"
|
|
55
|
+
require_relative "sadr/client"
|