canopus 0.1.0

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