canopus 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +148 -0
- data/docs/adr/001-runtime-gem-components.md +24 -0
- data/docs/adr/002-bounded-diff-engine.md +22 -0
- data/docs/adr/003-background-language-analysis.md +22 -0
- data/docs/adr/004-persistent-display-map.md +23 -0
- data/docs/adr/005-windows-runtime.md +27 -0
- data/docs/adr/README.md +36 -0
- data/docs/distribution.md +27 -0
- data/docs/lsp.md +38 -0
- data/docs/performance.md +40 -0
- data/docs/snippets.md +32 -0
- data/docs/vim.md +24 -0
- data/docs/workspace_edits.md +32 -0
- data/examples/native_smoke.rb +25 -0
- data/examples/plugins/word_count.rb +11 -0
- data/exe/canopus +19 -0
- data/lib/canopus/block_map.rb +19 -0
- data/lib/canopus/buffer.rb +293 -0
- data/lib/canopus/cli.rb +171 -0
- data/lib/canopus/controller.rb +507 -0
- data/lib/canopus/data_compat.rb +25 -0
- data/lib/canopus/display_map/line_builder.rb +54 -0
- data/lib/canopus/display_map/line_set.rb +8 -0
- data/lib/canopus/display_map/pending_line_set.rb +9 -0
- data/lib/canopus/display_map/summary.rb +23 -0
- data/lib/canopus/display_map/worker.rb +89 -0
- data/lib/canopus/display_map.rb +350 -0
- data/lib/canopus/display_point.rb +5 -0
- data/lib/canopus/editor/snippet_expandable.rb +281 -0
- data/lib/canopus/editor.rb +445 -0
- data/lib/canopus/error.rb +5 -0
- data/lib/canopus/fold_map.rb +87 -0
- data/lib/canopus/git/blame.rb +50 -0
- data/lib/canopus/git/commit.rb +7 -0
- data/lib/canopus/git/corrupt_object.rb +7 -0
- data/lib/canopus/git/diff.rb +131 -0
- data/lib/canopus/git/index.rb +94 -0
- data/lib/canopus/git/object_database.rb +45 -0
- data/lib/canopus/git/pack.rb +186 -0
- data/lib/canopus/git/repository.rb +313 -0
- data/lib/canopus/git/status.rb +74 -0
- data/lib/canopus/git/tree_entry.rb +7 -0
- data/lib/canopus/git.rb +15 -0
- data/lib/canopus/icon_theme.rb +43 -0
- data/lib/canopus/language/background_analysis/job.rb +12 -0
- data/lib/canopus/language/background_analysis/scheduler.rb +70 -0
- data/lib/canopus/language/background_analysis.rb +280 -0
- data/lib/canopus/language/definition.rb +7 -0
- data/lib/canopus/language/document.rb +143 -0
- data/lib/canopus/language/symbol.rb +7 -0
- data/lib/canopus/language/syntax_worker.rb +91 -0
- data/lib/canopus/language.rb +42 -0
- data/lib/canopus/lazy_rope.rb +218 -0
- data/lib/canopus/lsp/client.rb +299 -0
- data/lib/canopus/lsp/error.rb +7 -0
- data/lib/canopus/lsp/future/subscription.rb +9 -0
- data/lib/canopus/lsp/future.rb +87 -0
- data/lib/canopus/lsp/protocol.rb +83 -0
- data/lib/canopus/lsp/server_error.rb +13 -0
- data/lib/canopus/lsp/timeout.rb +7 -0
- data/lib/canopus/lsp/transport.rb +123 -0
- data/lib/canopus/lsp.rb +19 -0
- data/lib/canopus/markdown.rb +69 -0
- data/lib/canopus/match_data_compat.rb +17 -0
- data/lib/canopus/multi_buffer.rb +266 -0
- data/lib/canopus/pane.rb +61 -0
- data/lib/canopus/patch/composite.rb +12 -0
- data/lib/canopus/patch/reload.rb +18 -0
- data/lib/canopus/patch.rb +27 -0
- data/lib/canopus/performance_recorder.rb +207 -0
- data/lib/canopus/plugins/api.rb +50 -0
- data/lib/canopus/plugins/isolated_runtime.rb +167 -0
- data/lib/canopus/plugins/local_runtime.rb +25 -0
- data/lib/canopus/plugins/permission_denied.rb +7 -0
- data/lib/canopus/plugins/registry.rb +30 -0
- data/lib/canopus/plugins.rb +11 -0
- data/lib/canopus/project/ignore_matcher.rb +104 -0
- data/lib/canopus/project/search.rb +164 -0
- data/lib/canopus/project/search_worker/cancelled.rb +3 -0
- data/lib/canopus/project/search_worker/runner.rb +9 -0
- data/lib/canopus/project/search_worker.rb +108 -0
- data/lib/canopus/project/tree.rb +48 -0
- data/lib/canopus/project/watcher.rb +59 -0
- data/lib/canopus/project.rb +127 -0
- data/lib/canopus/regexp_compat.rb +30 -0
- data/lib/canopus/save_conflict.rb +5 -0
- data/lib/canopus/selection.rb +11 -0
- data/lib/canopus/settings.rb +118 -0
- data/lib/canopus/snippet/transform.rb +209 -0
- data/lib/canopus/snippet.rb +183 -0
- data/lib/canopus/tab_map.rb +30 -0
- data/lib/canopus/terminal/cell.rb +7 -0
- data/lib/canopus/terminal/grid.rb +321 -0
- data/lib/canopus/terminal/pty.rb +103 -0
- data/lib/canopus/terminal/scrollback.rb +38 -0
- data/lib/canopus/terminal/vt.rb +398 -0
- data/lib/canopus/terminal.rb +13 -0
- data/lib/canopus/theme.rb +43 -0
- data/lib/canopus/version.rb +5 -0
- data/lib/canopus/vim/commandable.rb +148 -0
- data/lib/canopus/vim/motionable.rb +321 -0
- data/lib/canopus/vim/operator_capable.rb +377 -0
- data/lib/canopus/vim/text_object_selectable.rb +141 -0
- data/lib/canopus/vim.rb +426 -0
- data/lib/canopus/workspace/edit/executable.rb +139 -0
- data/lib/canopus/workspace/edit/node.rb +8 -0
- data/lib/canopus/workspace/edit/plan.rb +170 -0
- data/lib/canopus/workspace/edit/resource_preparable.rb +98 -0
- data/lib/canopus/workspace/edit.rb +6 -0
- data/lib/canopus/workspace/file_change_aware.rb +40 -0
- data/lib/canopus/workspace/file_previewable.rb +31 -0
- data/lib/canopus/workspace/git_aware.rb +140 -0
- data/lib/canopus/workspace/language_aware.rb +413 -0
- data/lib/canopus/workspace/language_server_configurable.rb +181 -0
- data/lib/canopus/workspace/project_searchable.rb +208 -0
- data/lib/canopus/workspace/project_tree_editable.rb +82 -0
- data/lib/canopus/workspace/session_persistable.rb +153 -0
- data/lib/canopus/workspace/settings_aware.rb +91 -0
- data/lib/canopus/workspace/view/terminal_presentable.rb +185 -0
- data/lib/canopus/workspace/view.rb +643 -0
- data/lib/canopus/workspace.rb +525 -0
- data/lib/canopus/wrap_map.rb +108 -0
- data/lib/canopus.rb +24 -0
- data/sig/canopus.rbs +375 -0
- data/sig/controller.rbs +55 -0
- data/sig/display_stages.rbs +49 -0
- data/sig/git.rbs +140 -0
- data/sig/lsp.rbs +99 -0
- data/sig/markdown.rbs +11 -0
- data/sig/performance_recorder.rbs +23 -0
- data/sig/search_services.rbs +8 -0
- data/sig/services.rbs +81 -0
- data/sig/snippet.rbs +42 -0
- data/sig/terminal.rbs +125 -0
- data/sig/vim.rbs +27 -0
- data/sig/workspace_edits.rbs +11 -0
- data/sig/workspace_services.rbs +117 -0
- data/tools/certify_snippet_regex.rb +35 -0
- data/tools/check_dependencies.rb +80 -0
- data/tools/native_check.rb +70 -0
- data/tools/package.rb +113 -0
- data/tools/package_test.rb +32 -0
- metadata +314 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Canopus
|
|
4
|
+
# Read-only UTF-8 rope facade. Newline offsets are packed; document bytes stay
|
|
5
|
+
# on disk and a bounded LRU serves positioned pages independently of IO position.
|
|
6
|
+
class LazyRope
|
|
7
|
+
PAGE_SIZE = 65_536
|
|
8
|
+
attr_reader :bytesize, :cached_bytes, :line_ending
|
|
9
|
+
def initialize(path, page_limit: 32)
|
|
10
|
+
raise ArgumentError, "page limit must be positive" unless page_limit.is_a?(Integer) && page_limit.positive?
|
|
11
|
+
@file = File.open(path, File::RDONLY | File::BINARY | File::SHARE_DELETE)
|
|
12
|
+
@positioned_read, @read_lock = true, Mutex.new
|
|
13
|
+
@stamp = stamp
|
|
14
|
+
@origin = @file.size >= 3 && read_at(3, 0) == "\xEF\xBB\xBF".b ? 3 : 0
|
|
15
|
+
@bytesize = @file.size - @origin
|
|
16
|
+
@pages, @page_limit, @cached_bytes = {}, page_limit, 0
|
|
17
|
+
@starts = [0].pack("Q<")
|
|
18
|
+
@checkpoints = [0, 0, 0].pack("Q<3")
|
|
19
|
+
@length = @utf16_length = 0
|
|
20
|
+
index_lines
|
|
21
|
+
@starts.freeze
|
|
22
|
+
@checkpoints.freeze
|
|
23
|
+
@line_ending ||= "\n"
|
|
24
|
+
rescue StandardError
|
|
25
|
+
@file&.close
|
|
26
|
+
raise
|
|
27
|
+
end
|
|
28
|
+
def lazy? = true
|
|
29
|
+
def line_count = @starts.bytesize / 8
|
|
30
|
+
def length = @length
|
|
31
|
+
def utf16_length = @utf16_length
|
|
32
|
+
def empty? = @bytesize.zero?
|
|
33
|
+
def close = (@file.close unless @file.closed?)
|
|
34
|
+
def line_start(row)
|
|
35
|
+
raise RangeError, "line outside document" unless row.is_a?(Integer) && row.between?(0, line_count - 1)
|
|
36
|
+
@starts.unpack1("Q<", offset: row * 8)
|
|
37
|
+
end
|
|
38
|
+
def line(row)
|
|
39
|
+
start = line_start(row)
|
|
40
|
+
ending = row + 1 < line_count ? line_start(row + 1) : @bytesize
|
|
41
|
+
read_bytes(start, ending - start).force_encoding(Encoding::UTF_8).sub(/(?:\r\n|[\r\n\u2028\u2029])\z/, "")
|
|
42
|
+
end
|
|
43
|
+
def line_end(row)
|
|
44
|
+
ending = row + 1 < line_count ? line_start(row + 1) : @bytesize
|
|
45
|
+
return ending if row + 1 == line_count
|
|
46
|
+
tail = read_bytes([ending - 3, 0].max, [ending, 3].min)
|
|
47
|
+
ending - (tail.end_with?("\r\n") ? 2 : tail.end_with?("\xE2\x80\xA8".b, "\xE2\x80\xA9".b) ? 3 : 1)
|
|
48
|
+
end
|
|
49
|
+
def line_window(row, from: 0, max_bytes: 16_384)
|
|
50
|
+
start, ending = line_start(row), line_end(row)
|
|
51
|
+
offset = (start + from).clamp(start, ending)
|
|
52
|
+
offset -= 1 while offset > start && offset < ending && (read_bytes(offset, 1).getbyte(0) & 0xc0) == 0x80
|
|
53
|
+
value = read_bytes(offset, [max_bytes, ending - offset].min)
|
|
54
|
+
value = value.byteslice(0, complete_utf8_length(value)) unless value.empty?
|
|
55
|
+
[value.force_encoding(Encoding::UTF_8), offset - start]
|
|
56
|
+
end
|
|
57
|
+
def byteslice(offset, count = nil)
|
|
58
|
+
if offset.is_a?(Range)
|
|
59
|
+
range = offset
|
|
60
|
+
offset = range.begin
|
|
61
|
+
count = range.end + (range.exclude_end? ? 0 : 1) - offset
|
|
62
|
+
end
|
|
63
|
+
count ||= @bytesize - offset
|
|
64
|
+
validate_offset(offset)
|
|
65
|
+
validate_offset(offset + count)
|
|
66
|
+
raise RangeError, "negative slice length" if count.negative?
|
|
67
|
+
Denebola::Rope.new(read_bytes(offset, count).force_encoding(Encoding::UTF_8))
|
|
68
|
+
end
|
|
69
|
+
def point_at(offset)
|
|
70
|
+
validate_offset(offset)
|
|
71
|
+
low, high = 0, line_count
|
|
72
|
+
while low < high
|
|
73
|
+
middle = (low + high) / 2
|
|
74
|
+
line_start(middle) <= offset ? low = middle + 1 : high = middle
|
|
75
|
+
end
|
|
76
|
+
row = [low - 1, 0].max
|
|
77
|
+
Denebola::Point.new(row, prefix_count(offset) - prefix_count(line_start(row)))
|
|
78
|
+
end
|
|
79
|
+
def offset_at(point)
|
|
80
|
+
start, ending = line_start(point.row), line_end(point.row)
|
|
81
|
+
base = prefix_count(start)
|
|
82
|
+
raise RangeError, "column outside line" unless point.column.is_a?(Integer) && point.column.between?(0, prefix_count(ending) - base)
|
|
83
|
+
offset_for_count(base + point.column)
|
|
84
|
+
end
|
|
85
|
+
def utf16_point_at(offset)
|
|
86
|
+
point = point_at(offset)
|
|
87
|
+
Denebola::Point.new(point.row, prefix_count(offset, utf16: true) - prefix_count(line_start(point.row), utf16: true))
|
|
88
|
+
end
|
|
89
|
+
def offset_at_utf16_point(point)
|
|
90
|
+
base = prefix_count(line_start(point.row), utf16: true)
|
|
91
|
+
ending = prefix_count(line_end(point.row), utf16: true)
|
|
92
|
+
raise RangeError, "UTF-16 column outside line" unless point.column.is_a?(Integer) && point.column.between?(0, ending - base)
|
|
93
|
+
offset_for_count(base + point.column, utf16: true)
|
|
94
|
+
end
|
|
95
|
+
def utf16_offset_at(offset)
|
|
96
|
+
validate_offset(offset)
|
|
97
|
+
prefix_count(offset, utf16: true)
|
|
98
|
+
end
|
|
99
|
+
def offset_at_utf16(units)
|
|
100
|
+
raise RangeError, "UTF-16 offset outside document" unless units.is_a?(Integer) && units.between?(0, @utf16_length)
|
|
101
|
+
offset_for_count(units, utf16: true)
|
|
102
|
+
end
|
|
103
|
+
def each_chunk
|
|
104
|
+
return enum_for(__method__) unless block_given?
|
|
105
|
+
offset = 0
|
|
106
|
+
while offset < @bytesize
|
|
107
|
+
value = read_bytes(offset, [PAGE_SIZE, @bytesize - offset].min)
|
|
108
|
+
finish = complete_utf8_length(value)
|
|
109
|
+
raise Error, "invalid UTF-8 file" if finish.zero?
|
|
110
|
+
yield value.byteslice(0, finish).force_encoding(Encoding::UTF_8)
|
|
111
|
+
offset += finish
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
def to_s = each_chunk.to_a.join
|
|
115
|
+
def apply_edits(*) = raise(Error, "large file is read-only")
|
|
116
|
+
def check_invariants!
|
|
117
|
+
raise Error, "invalid line index" unless line_start(0).zero? && @starts.bytesize % 8 == 0
|
|
118
|
+
true
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
def checkpoint(value, dimension)
|
|
123
|
+
low, high = 0, @checkpoints.bytesize / 24
|
|
124
|
+
while low < high
|
|
125
|
+
middle = (low + high) / 2
|
|
126
|
+
@checkpoints.unpack1("Q<", offset: middle * 24 + dimension * 8) <= value ? low = middle + 1 : high = middle
|
|
127
|
+
end
|
|
128
|
+
@checkpoints.byteslice([low - 1, 0].max * 24, 24).unpack("Q<3")
|
|
129
|
+
end
|
|
130
|
+
def prefix_count(offset, utf16: false)
|
|
131
|
+
base, chars, units = checkpoint(offset, 0)
|
|
132
|
+
value = read_bytes(base, offset - base).force_encoding(Encoding::UTF_8)
|
|
133
|
+
utf16 ? units + (value.ascii_only? ? value.bytesize : value.encode(Encoding::UTF_16LE).bytesize / 2) : chars + value.length
|
|
134
|
+
end
|
|
135
|
+
def offset_for_count(count, utf16: false)
|
|
136
|
+
offset, chars, units = checkpoint(count, utf16 ? 2 : 1)
|
|
137
|
+
remaining = count - (utf16 ? units : chars)
|
|
138
|
+
return offset if remaining.zero?
|
|
139
|
+
value = read_bytes(offset, [PAGE_SIZE + 4, @bytesize - offset].min).force_encoding(Encoding::UTF_8)
|
|
140
|
+
value = value.byteslice(0, complete_utf8_length(value))
|
|
141
|
+
value.each_codepoint do |codepoint|
|
|
142
|
+
break if remaining.zero?
|
|
143
|
+
remaining -= utf16 && codepoint > 0xffff ? 2 : 1
|
|
144
|
+
raise RangeError, "inside UTF-16 surrogate pair" if remaining.negative?
|
|
145
|
+
offset += codepoint < 0x80 ? 1 : codepoint < 0x800 ? 2 : codepoint < 0x10000 ? 3 : 4
|
|
146
|
+
end
|
|
147
|
+
raise RangeError, "column outside document" unless remaining.zero?
|
|
148
|
+
offset
|
|
149
|
+
end
|
|
150
|
+
def stamp
|
|
151
|
+
stat = @file.stat
|
|
152
|
+
[stat.size, stat.mtime, stat.ctime]
|
|
153
|
+
end
|
|
154
|
+
def validate_offset(offset)
|
|
155
|
+
raise RangeError, "offset outside document" unless offset.is_a?(Integer) && offset.between?(0, @bytesize)
|
|
156
|
+
raise RangeError, "offset inside UTF-8 character" if offset < @bytesize && (read_bytes(offset, 1).getbyte(0) & 0xC0) == 0x80
|
|
157
|
+
end
|
|
158
|
+
def read_bytes(offset, count)
|
|
159
|
+
raise SaveConflict, "large file changed on disk; reopen it" unless stamp == @stamp
|
|
160
|
+
output = String.new(capacity: count, encoding: Encoding::BINARY)
|
|
161
|
+
while count.positive?
|
|
162
|
+
page, local = offset.divmod(PAGE_SIZE)
|
|
163
|
+
data = @pages.delete(page)
|
|
164
|
+
unless data
|
|
165
|
+
data = read_at([PAGE_SIZE, @bytesize - page * PAGE_SIZE].min, @origin + page * PAGE_SIZE)
|
|
166
|
+
@cached_bytes += data.bytesize
|
|
167
|
+
@cached_bytes -= @pages.shift.last.bytesize while @pages.length >= @page_limit
|
|
168
|
+
end
|
|
169
|
+
@pages[page] = data
|
|
170
|
+
length = [count, data.bytesize - local].min
|
|
171
|
+
raise SaveConflict, "large file was truncated" unless length.positive?
|
|
172
|
+
output << data.byteslice(local, length)
|
|
173
|
+
offset += length
|
|
174
|
+
count -= length
|
|
175
|
+
end
|
|
176
|
+
output
|
|
177
|
+
end
|
|
178
|
+
def complete_utf8_length(value)
|
|
179
|
+
index = value.bytesize - 1
|
|
180
|
+
index -= 1 while index.positive? && (value.getbyte(index) & 0xC0) == 0x80
|
|
181
|
+
leading = value.getbyte(index)
|
|
182
|
+
length = leading < 0x80 ? 1 : leading < 0xE0 ? 2 : leading < 0xF0 ? 3 : 4
|
|
183
|
+
value.bytesize - index < length ? index : value.bytesize
|
|
184
|
+
end
|
|
185
|
+
def read_at(count, offset)
|
|
186
|
+
return @file.pread(count, offset) if @positioned_read
|
|
187
|
+
@read_lock.synchronize { @file.seek(offset); @file.read(count) }
|
|
188
|
+
rescue NotImplementedError
|
|
189
|
+
@positioned_read = false
|
|
190
|
+
retry
|
|
191
|
+
end
|
|
192
|
+
def index_lines
|
|
193
|
+
offset, pending = 0, +"".b
|
|
194
|
+
while offset < @bytesize
|
|
195
|
+
chunk = read_at([PAGE_SIZE, @bytesize - offset].min, @origin + offset)
|
|
196
|
+
pending << chunk
|
|
197
|
+
offset += chunk.bytesize
|
|
198
|
+
complete = complete_utf8_length(pending)
|
|
199
|
+
complete -= 1 if complete.positive? && pending.getbyte(complete - 1) == 13 && offset < @bytesize
|
|
200
|
+
text = pending.byteslice(0, complete).force_encoding(Encoding::UTF_8)
|
|
201
|
+
raise Error, "large-file mode requires valid UTF-8 text" unless text.valid_encoding? && !text.include?("\0")
|
|
202
|
+
base = offset - pending.bytesize
|
|
203
|
+
binary = text.b
|
|
204
|
+
starts = []
|
|
205
|
+
binary.scan(/\r\n|[\r\n]|\xE2\x80[\xA8\xA9]/n) do |ending|
|
|
206
|
+
@line_ending ||= ending.dup.force_encoding(Encoding::UTF_8)
|
|
207
|
+
starts << base + Regexp.last_match.end(0)
|
|
208
|
+
end
|
|
209
|
+
@starts << starts.pack("Q<*")
|
|
210
|
+
@length += text.length
|
|
211
|
+
@utf16_length += text.ascii_only? ? text.bytesize : text.encode(Encoding::UTF_16LE).bytesize / 2
|
|
212
|
+
@checkpoints << [base + complete, @length, @utf16_length].pack("Q<3")
|
|
213
|
+
pending = pending.byteslice(complete..)
|
|
214
|
+
end
|
|
215
|
+
raise Error, "truncated UTF-8 file" unless pending.empty?
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
@@ -0,0 +1,299 @@
|
|
|
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
|
+
@state = :running
|
|
50
|
+
self
|
|
51
|
+
rescue StandardError => error
|
|
52
|
+
@transport&.close if epoch
|
|
53
|
+
fail_pending(error) if epoch
|
|
54
|
+
@state = :failed if epoch
|
|
55
|
+
raise
|
|
56
|
+
end
|
|
57
|
+
def request(method, params = {})
|
|
58
|
+
id = @lock.synchronize { @sequence += 1 }
|
|
59
|
+
future = Future.new(id, on_error: method(:report_error)) { |number| cancel(number) }
|
|
60
|
+
@lock.synchronize { @pending[id] = future }
|
|
61
|
+
raise Error, "language server is not connected" unless @transport&.alive?
|
|
62
|
+
@transport.write(jsonrpc: "2.0", id: id, method: method.to_s, params: params)
|
|
63
|
+
future
|
|
64
|
+
rescue StandardError => error
|
|
65
|
+
@lock.synchronize { @pending.delete(id) }
|
|
66
|
+
future.fulfill(error: error)
|
|
67
|
+
future
|
|
68
|
+
end
|
|
69
|
+
def notify(method, params = {})
|
|
70
|
+
raise Error, "language server is not connected" unless @transport&.alive?
|
|
71
|
+
@transport.write(jsonrpc: "2.0", method: method.to_s, params: params)
|
|
72
|
+
end
|
|
73
|
+
def on(method, &handler)
|
|
74
|
+
raise ArgumentError, "handler required" unless handler
|
|
75
|
+
@handlers[method.to_s] = handler
|
|
76
|
+
end
|
|
77
|
+
def supports?(capability) = !!@capabilities[capability.to_s]
|
|
78
|
+
def workspace_symbols(query) = request("workspace/symbol", {query: query})
|
|
79
|
+
def resolve_completion(item) = request("completionItem/resolve", item)
|
|
80
|
+
def resolve_code_action(action) = request("codeAction/resolve", action)
|
|
81
|
+
def resolve_code_lens(lens) = request("codeLens/resolve", lens)
|
|
82
|
+
def execute_command(command, arguments: []) = request("workspace/executeCommand", {command: command, arguments: arguments})
|
|
83
|
+
|
|
84
|
+
def open_document(buffer, language_id:)
|
|
85
|
+
raise Error, "LSP document needs a path" unless buffer.path
|
|
86
|
+
uri = Protocol.uri(buffer.path)
|
|
87
|
+
@documents[uri]&.last&.detach
|
|
88
|
+
subscription = buffer.on_edit { |patch| change_document(uri, buffer, patch) }
|
|
89
|
+
@documents[uri] = [buffer, language_id, subscription]
|
|
90
|
+
notify("textDocument/didOpen", {textDocument: {uri: uri, languageId: language_id, version: buffer.version, text: buffer.text}}) if open_close?
|
|
91
|
+
uri
|
|
92
|
+
end
|
|
93
|
+
def close_document(uri)
|
|
94
|
+
@documents.delete(uri)&.last&.detach
|
|
95
|
+
@diagnostics.delete(uri)
|
|
96
|
+
@semantic.delete(uri)
|
|
97
|
+
notify("textDocument/didClose", {textDocument: {uri: uri}}) if open_close?
|
|
98
|
+
end
|
|
99
|
+
def save_document(uri)
|
|
100
|
+
sync = @capabilities["textDocumentSync"]
|
|
101
|
+
save = sync.is_a?(Hash) ? sync["save"] : sync.is_a?(Integer) && sync.positive?
|
|
102
|
+
return unless save
|
|
103
|
+
params = {textDocument: {uri: uri}}
|
|
104
|
+
params[:text] = @documents.fetch(uri).first.text if save.is_a?(Hash) && save["includeText"]
|
|
105
|
+
notify("textDocument/didSave", params)
|
|
106
|
+
end
|
|
107
|
+
def at(method, buffer, offset, **params)
|
|
108
|
+
request("textDocument/#{method}", {textDocument: {uri: Protocol.uri(buffer.path)}, position: Protocol.position(buffer.rope, offset), **params})
|
|
109
|
+
end
|
|
110
|
+
%w[completion hover definition typeDefinition implementation references rename signatureHelp].each do |method|
|
|
111
|
+
define_method(method) { |buffer, offset, **params| at(method, buffer, offset, **params) }
|
|
112
|
+
end
|
|
113
|
+
%w[documentSymbol formatting codeAction inlayHint codeLens diagnostic].each do |method|
|
|
114
|
+
define_method(method) { |buffer, **params| request("textDocument/#{method}", {textDocument: {uri: Protocol.uri(buffer.path)}, **params}) }
|
|
115
|
+
end
|
|
116
|
+
def semantic_tokens(buffer)
|
|
117
|
+
uri = Protocol.uri(buffer.path)
|
|
118
|
+
version = buffer.version
|
|
119
|
+
provider = @capabilities["semanticTokensProvider"]
|
|
120
|
+
return [] unless provider.is_a?(Hash) && provider["full"]
|
|
121
|
+
previous = @semantic[uri]
|
|
122
|
+
delta = previous && previous[0] && provider["full"].is_a?(Hash) && provider["full"]["delta"]
|
|
123
|
+
method = delta ? "textDocument/semanticTokens/full/delta" : "textDocument/semanticTokens/full"
|
|
124
|
+
params = {textDocument: {uri: uri}}
|
|
125
|
+
params[:previousResultId] = previous[0] if delta
|
|
126
|
+
result = request(method, params).await
|
|
127
|
+
return [] unless result && buffer.version == version
|
|
128
|
+
raise Error, "invalid semantic token result" unless result.is_a?(Hash) && (!result.key?("resultId") || result["resultId"].is_a?(String))
|
|
129
|
+
raise Error, "unexpected semantic token delta" if !delta && !result.key?("data")
|
|
130
|
+
data = result["data"] || Protocol.semantic_delta(previous[1], result["edits"])
|
|
131
|
+
tokens = Protocol.semantic_tokens(data, legend: provider["legend"])
|
|
132
|
+
@semantic[uri] = [result["resultId"], data]
|
|
133
|
+
tokens
|
|
134
|
+
end
|
|
135
|
+
def apply_text_edits(buffer, edits)
|
|
136
|
+
changes = edits.map do |edit|
|
|
137
|
+
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?
|
|
138
|
+
range = edit.fetch("range")
|
|
139
|
+
[Protocol.offset(buffer.rope, range.fetch("start"))...Protocol.offset(buffer.rope, range.fetch("end")), edit.fetch("newText")]
|
|
140
|
+
end
|
|
141
|
+
buffer.edit(changes, kind: :lsp)
|
|
142
|
+
end
|
|
143
|
+
def stop
|
|
144
|
+
@closing = true
|
|
145
|
+
begin
|
|
146
|
+
request("shutdown").await(timeout: 2) if @state == :running
|
|
147
|
+
notify("exit") if @transport&.alive?
|
|
148
|
+
rescue Error
|
|
149
|
+
nil
|
|
150
|
+
ensure
|
|
151
|
+
@epoch += 1
|
|
152
|
+
@documents.each_value { |_, _, subscription| subscription.detach }
|
|
153
|
+
@documents.clear
|
|
154
|
+
@semantic.clear
|
|
155
|
+
@diagnostics.clear
|
|
156
|
+
@transport&.close
|
|
157
|
+
fail_pending(Error.new("language server stopped"))
|
|
158
|
+
@state = :stopped
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
def open_close?
|
|
164
|
+
sync = @capabilities["textDocumentSync"]
|
|
165
|
+
sync.is_a?(Hash) ? sync["openClose"] : sync.is_a?(Integer) && sync.positive?
|
|
166
|
+
end
|
|
167
|
+
def change_document(uri, buffer, patch)
|
|
168
|
+
sync = @capabilities["textDocumentSync"]
|
|
169
|
+
mode = sync.is_a?(Hash) ? sync["change"] : sync
|
|
170
|
+
return unless [1, 2].include?(mode)
|
|
171
|
+
changes = if mode == 2 && patch.is_a?(Patch)
|
|
172
|
+
patch.edits.reverse.map { |edit| {range: Protocol.range(patch.before, edit.old_range), text: edit.new_text} }
|
|
173
|
+
else
|
|
174
|
+
[{text: buffer.text}]
|
|
175
|
+
end
|
|
176
|
+
notify("textDocument/didChange", {textDocument: {uri: uri, version: buffer.version}, contentChanges: changes})
|
|
177
|
+
rescue Error => error
|
|
178
|
+
report_error(error)
|
|
179
|
+
end
|
|
180
|
+
def cancel(id)
|
|
181
|
+
@lock.synchronize { @pending.delete(id) }
|
|
182
|
+
notify("$/cancelRequest", {id: id})
|
|
183
|
+
rescue Error
|
|
184
|
+
nil
|
|
185
|
+
end
|
|
186
|
+
def fail_pending(error)
|
|
187
|
+
pending = @lock.synchronize { values = @pending.values; @pending.clear; values }
|
|
188
|
+
pending.each { |future| future.fulfill(error: error) }
|
|
189
|
+
end
|
|
190
|
+
def report_error(error)
|
|
191
|
+
bounded = Error.new("#{error.class}: #{error.message}".scrub.byteslice(0, 2048).scrub(""))
|
|
192
|
+
@lock.synchronize do
|
|
193
|
+
@errors << bounded
|
|
194
|
+
@errors.shift if @errors.length > 200
|
|
195
|
+
end
|
|
196
|
+
@dispatch.call do
|
|
197
|
+
begin
|
|
198
|
+
@handlers["error"]&.call(bounded)
|
|
199
|
+
rescue StandardError
|
|
200
|
+
nil
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
rescue StandardError
|
|
204
|
+
nil
|
|
205
|
+
end
|
|
206
|
+
def receive(message, error, epoch = @epoch)
|
|
207
|
+
return unless epoch == @epoch
|
|
208
|
+
if error
|
|
209
|
+
running = @state == :running
|
|
210
|
+
@state = :failed
|
|
211
|
+
fail_pending(Error.new(error.message))
|
|
212
|
+
report_error(error)
|
|
213
|
+
restart_server if running && @restart && !@closing && @restarts < 3
|
|
214
|
+
elsif message.key?("id") && !message.key?("method")
|
|
215
|
+
future = @lock.synchronize { @pending.delete(message["id"]) }
|
|
216
|
+
future&.fulfill(message["result"], error: message["error"] && ServerError.new(message["error"]))
|
|
217
|
+
elsif message["method"]
|
|
218
|
+
method, params = message["method"], message.fetch("params", {})
|
|
219
|
+
@dispatch.call do
|
|
220
|
+
next unless epoch == @epoch && !@closing
|
|
221
|
+
begin
|
|
222
|
+
if method == "textDocument/publishDiagnostics"
|
|
223
|
+
raise Error, "invalid diagnostics notification" unless params.is_a?(Hash) && params["uri"].is_a?(String) && params["diagnostics"].is_a?(Array)
|
|
224
|
+
document = @documents[params["uri"]]
|
|
225
|
+
version = params["version"]
|
|
226
|
+
raise Error, "invalid diagnostic version" if !version.nil? && !version.is_a?(Integer)
|
|
227
|
+
next if document && version && version < document.first.version
|
|
228
|
+
@diagnostics[params["uri"]] = Protocol.diagnostics(params["diagnostics"])
|
|
229
|
+
end
|
|
230
|
+
known = @handlers.key?(method)
|
|
231
|
+
result = @handlers[method]&.call(params)
|
|
232
|
+
next unless message.key?("id")
|
|
233
|
+
unless known
|
|
234
|
+
known = true
|
|
235
|
+
result = case method
|
|
236
|
+
when "workspace/configuration"
|
|
237
|
+
params.fetch("items").map do |item|
|
|
238
|
+
section = item["section"]
|
|
239
|
+
section ? @configuration.dig(*section.split(".")) : @configuration
|
|
240
|
+
end
|
|
241
|
+
when "workspace/workspaceFolders"
|
|
242
|
+
[{uri: Protocol.uri(@root), name: File.basename(@root)}]
|
|
243
|
+
when "window/workDoneProgress/create", "workspace/semanticTokens/refresh", "workspace/inlayHint/refresh", "workspace/codeLens/refresh", "workspace/diagnostic/refresh"
|
|
244
|
+
@semantic.clear if method == "workspace/semanticTokens/refresh"
|
|
245
|
+
nil
|
|
246
|
+
else
|
|
247
|
+
known = false
|
|
248
|
+
nil
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
if !known
|
|
252
|
+
reply(message["id"], epoch, error: {code: -32601, message: "unsupported client request #{method}"})
|
|
253
|
+
elsif result.is_a?(Future)
|
|
254
|
+
result.then do |value, failure|
|
|
255
|
+
failure ? reply(message["id"], epoch, error: {code: -32603, message: failure.message.byteslice(0, 2048).scrub}) : reply(message["id"], epoch, value: value)
|
|
256
|
+
end
|
|
257
|
+
else
|
|
258
|
+
reply(message["id"], epoch, value: result)
|
|
259
|
+
end
|
|
260
|
+
rescue StandardError => failure
|
|
261
|
+
report_error(failure)
|
|
262
|
+
reply(message["id"], epoch, error: {code: -32603, message: "client request handler failed"}) if message.key?("id")
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
rescue StandardError => failure
|
|
267
|
+
report_error(failure)
|
|
268
|
+
reply(message["id"], epoch, error: {code: -32603, message: "client dispatch failed"}) if message&.key?("id") && message.key?("method")
|
|
269
|
+
end
|
|
270
|
+
def reply(id, epoch, value: nil, error: nil)
|
|
271
|
+
return unless epoch == @epoch && !@closing
|
|
272
|
+
response = {jsonrpc: "2.0", id: id}
|
|
273
|
+
error ? response[:error] = error : response[:result] = value
|
|
274
|
+
@transport.write(response)
|
|
275
|
+
rescue StandardError => failure
|
|
276
|
+
report_error(failure)
|
|
277
|
+
end
|
|
278
|
+
def restart_server
|
|
279
|
+
return if @restart_thread&.alive?
|
|
280
|
+
previous = @transport
|
|
281
|
+
@restart_thread = Thread.new do
|
|
282
|
+
previous.close
|
|
283
|
+
until @closing || @restarts >= 3
|
|
284
|
+
@restarts += 1
|
|
285
|
+
sleep(0.2 * @restarts)
|
|
286
|
+
break if @closing
|
|
287
|
+
begin
|
|
288
|
+
start
|
|
289
|
+
@documents.values.dup.each { |buffer, language, _| open_document(buffer, language_id: language) }
|
|
290
|
+
break
|
|
291
|
+
rescue StandardError => failure
|
|
292
|
+
report_error(failure)
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
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
|