rigortype 0.1.4 → 0.1.6
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 +4 -4
- data/README.md +69 -56
- data/lib/rigor/analysis/buffer_binding.rb +36 -0
- data/lib/rigor/analysis/check_rules.rb +11 -1
- data/lib/rigor/analysis/dependency_source_inference/index.rb +14 -1
- data/lib/rigor/analysis/dependency_source_inference/return_type_heuristic.rb +105 -0
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +32 -12
- data/lib/rigor/analysis/fact_store.rb +15 -3
- data/lib/rigor/analysis/project_scan.rb +39 -0
- data/lib/rigor/analysis/result.rb +11 -3
- data/lib/rigor/analysis/run_stats.rb +193 -0
- data/lib/rigor/analysis/runner.rb +681 -19
- data/lib/rigor/analysis/worker_session.rb +339 -0
- data/lib/rigor/builtins/hkt_builtins.rb +342 -0
- data/lib/rigor/builtins/imported_refinements.rb +6 -2
- data/lib/rigor/builtins/regex_refinement.rb +17 -12
- data/lib/rigor/builtins/static_return_refinements.rb +120 -0
- data/lib/rigor/cache/rbs_descriptor.rb +3 -1
- data/lib/rigor/cache/store.rb +72 -9
- data/lib/rigor/cli/lsp_command.rb +129 -0
- data/lib/rigor/cli/type_of_command.rb +44 -5
- data/lib/rigor/cli.rb +122 -10
- data/lib/rigor/configuration.rb +168 -7
- data/lib/rigor/environment/bundle_sig_discovery.rb +198 -0
- data/lib/rigor/environment/class_registry.rb +12 -3
- data/lib/rigor/environment/hkt_registry_holder.rb +33 -0
- data/lib/rigor/environment/lockfile_resolver.rb +125 -0
- data/lib/rigor/environment/rbs_collection_discovery.rb +126 -0
- data/lib/rigor/environment/rbs_coverage_report.rb +112 -0
- data/lib/rigor/environment/rbs_loader.rb +238 -7
- data/lib/rigor/environment/reflection.rb +152 -0
- data/lib/rigor/environment/reporters.rb +40 -0
- data/lib/rigor/environment.rb +179 -10
- data/lib/rigor/inference/acceptance.rb +83 -4
- data/lib/rigor/inference/builtins/method_catalog.rb +12 -5
- data/lib/rigor/inference/builtins/numeric_catalog.rb +15 -4
- data/lib/rigor/inference/expression_typer.rb +59 -2
- data/lib/rigor/inference/hkt_body.rb +171 -0
- data/lib/rigor/inference/hkt_body_parser.rb +363 -0
- data/lib/rigor/inference/hkt_reducer.rb +256 -0
- data/lib/rigor/inference/hkt_registry.rb +223 -0
- data/lib/rigor/inference/macro_block_self_type.rb +96 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +29 -29
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -4
- data/lib/rigor/inference/method_dispatcher/method_folding.rb +18 -1
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +126 -31
- data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +87 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +46 -40
- data/lib/rigor/inference/method_dispatcher.rb +282 -6
- data/lib/rigor/inference/method_parameter_binder.rb +21 -11
- data/lib/rigor/inference/narrowing.rb +127 -8
- data/lib/rigor/inference/project_patched_methods.rb +70 -0
- data/lib/rigor/inference/project_patched_scanner.rb +210 -0
- data/lib/rigor/inference/scope_indexer.rb +156 -12
- data/lib/rigor/inference/statement_evaluator.rb +106 -6
- data/lib/rigor/inference/synthetic_method.rb +86 -0
- data/lib/rigor/inference/synthetic_method_index.rb +82 -0
- data/lib/rigor/inference/synthetic_method_scanner.rb +599 -0
- data/lib/rigor/language_server/buffer_table.rb +63 -0
- data/lib/rigor/language_server/completion_provider.rb +438 -0
- data/lib/rigor/language_server/debouncer.rb +86 -0
- data/lib/rigor/language_server/diagnostic_publisher.rb +167 -0
- data/lib/rigor/language_server/document_symbol_provider.rb +142 -0
- data/lib/rigor/language_server/folding_range_provider.rb +75 -0
- data/lib/rigor/language_server/hover_provider.rb +74 -0
- data/lib/rigor/language_server/hover_renderer.rb +312 -0
- data/lib/rigor/language_server/loop.rb +71 -0
- data/lib/rigor/language_server/project_context.rb +145 -0
- data/lib/rigor/language_server/selection_range_provider.rb +93 -0
- data/lib/rigor/language_server/server.rb +384 -0
- data/lib/rigor/language_server/signature_help_provider.rb +249 -0
- data/lib/rigor/language_server/synchronized_writer.rb +28 -0
- data/lib/rigor/language_server/uri.rb +40 -0
- data/lib/rigor/language_server.rb +29 -0
- data/lib/rigor/plugin/base.rb +63 -0
- data/lib/rigor/plugin/blueprint.rb +60 -0
- data/lib/rigor/plugin/loader.rb +3 -1
- data/lib/rigor/plugin/macro/block_as_method.rb +131 -0
- data/lib/rigor/plugin/macro/external_file.rb +143 -0
- data/lib/rigor/plugin/macro/heredoc_template.rb +315 -0
- data/lib/rigor/plugin/macro/trait_registry.rb +198 -0
- data/lib/rigor/plugin/macro.rb +31 -0
- data/lib/rigor/plugin/manifest.rb +127 -9
- data/lib/rigor/plugin/registry.rb +51 -2
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/rbs_extended/hkt_directives.rb +326 -0
- data/lib/rigor/rbs_extended.rb +82 -2
- data/lib/rigor/sig_gen/generator.rb +12 -3
- data/lib/rigor/trinary.rb +15 -11
- data/lib/rigor/type/app.rb +107 -0
- data/lib/rigor/type/bot.rb +6 -3
- data/lib/rigor/type/combinator.rb +12 -1
- data/lib/rigor/type/integer_range.rb +7 -7
- data/lib/rigor/type/refined.rb +18 -12
- data/lib/rigor/type/top.rb +4 -3
- data/lib/rigor/type.rb +1 -0
- data/lib/rigor/type_node/generic.rb +7 -1
- data/lib/rigor/type_node/identifier.rb +9 -1
- data/lib/rigor/type_node/string_literal.rb +4 -1
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/environment.rbs +11 -4
- data/sig/rigor/inference.rbs +2 -0
- data/sig/rigor/plugin/blueprint.rbs +7 -0
- data/sig/rigor/plugin/manifest.rbs +1 -1
- data/sig/rigor/plugin/registry.rbs +14 -1
- data/sig/rigor.rbs +37 -2
- metadata +92 -1
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../version"
|
|
4
|
+
require_relative "buffer_table"
|
|
5
|
+
|
|
6
|
+
module Rigor
|
|
7
|
+
module LanguageServer
|
|
8
|
+
# LSP server lifecycle state machine + JSON-RPC method dispatcher.
|
|
9
|
+
#
|
|
10
|
+
# Slice 1 (this commit) ships:
|
|
11
|
+
# - State machine: `:uninitialized` → `:initialized` → `:shutdown`
|
|
12
|
+
# → `:exited`.
|
|
13
|
+
# - Three lifecycle handlers: `initialize`, `shutdown`, `exit`.
|
|
14
|
+
# - {#dispatch} which routes (method, params) to the matching
|
|
15
|
+
# handler and returns the response payload (or `nil` for
|
|
16
|
+
# notifications). Out-of-state requests return the
|
|
17
|
+
# spec-defined `InvalidRequest` (-32002) / `MethodNotFound`
|
|
18
|
+
# (-32601) error shapes.
|
|
19
|
+
#
|
|
20
|
+
# Slice 2 wraps this dispatcher in a stdio JSON-RPC reader /
|
|
21
|
+
# writer so the CLI subcommand can serve real LSP clients.
|
|
22
|
+
# Slice 3+ adds document sync; slice 4+ adds publishDiagnostics;
|
|
23
|
+
# slice 5-8 add the rest of the v1 capability surface.
|
|
24
|
+
class Server # rubocop:disable Metrics/ClassLength
|
|
25
|
+
# JSON-RPC error codes per LSP spec § "Response Message".
|
|
26
|
+
ERROR_PARSE_ERROR = -32_700
|
|
27
|
+
ERROR_INVALID_REQUEST = -32_600
|
|
28
|
+
ERROR_METHOD_NOT_FOUND = -32_601
|
|
29
|
+
ERROR_INVALID_PARAMS = -32_602
|
|
30
|
+
ERROR_INTERNAL_ERROR = -32_603
|
|
31
|
+
# LSP-specific reserved codes.
|
|
32
|
+
ERROR_SERVER_NOT_INITIALIZED = -32_002
|
|
33
|
+
ERROR_INVALID_REQUEST_AFTER_SHUTDOWN = -32_600
|
|
34
|
+
|
|
35
|
+
# `TextDocumentSyncKind::Full = 1`. Slice 10 (deferred)
|
|
36
|
+
# promotes to `Incremental = 2`.
|
|
37
|
+
TEXT_DOCUMENT_SYNC_FULL = 1
|
|
38
|
+
|
|
39
|
+
# Methods callable BEFORE `initialize`. Per LSP spec § 3 only
|
|
40
|
+
# `initialize` and `exit` are allowed pre-initialization; every
|
|
41
|
+
# other request returns `ServerNotInitialized`. We also accept
|
|
42
|
+
# `shutdown` so a sequence like `initialize → shutdown → exit`
|
|
43
|
+
# (the conformance harness) round-trips even when the client
|
|
44
|
+
# skips real work.
|
|
45
|
+
PRE_INITIALIZE_METHODS = %w[initialize shutdown exit].freeze
|
|
46
|
+
|
|
47
|
+
attr_reader :state, :exit_code, :buffer_table, :publisher,
|
|
48
|
+
:hover_provider, :document_symbol_provider, :completion_provider,
|
|
49
|
+
:signature_help_provider, :folding_range_provider,
|
|
50
|
+
:selection_range_provider, :project_context
|
|
51
|
+
|
|
52
|
+
# @param completion_provider [Rigor::LanguageServer::CompletionProvider, nil]
|
|
53
|
+
# resolves `textDocument/completion`. Nil → `MethodNotFound`.
|
|
54
|
+
# @param signature_help_provider [Rigor::LanguageServer::SignatureHelpProvider, nil]
|
|
55
|
+
# resolves `textDocument/signatureHelp`. Nil → `MethodNotFound`.
|
|
56
|
+
# @param project_context [Rigor::LanguageServer::ProjectContext, nil]
|
|
57
|
+
# the per-session cache of `Environment` + `Cache::Store`
|
|
58
|
+
# the providers read on every request. When present,
|
|
59
|
+
# `workspace/didChangeWatchedFiles` and
|
|
60
|
+
# `workspace/didChangeConfiguration` invalidate the cache;
|
|
61
|
+
# nil means "no project context", which is the slice 1-6
|
|
62
|
+
# behaviour (each request rebuilds env from scratch).
|
|
63
|
+
def initialize(buffer_table: BufferTable.new, publisher: nil, # rubocop:disable Metrics/ParameterLists
|
|
64
|
+
hover_provider: nil, document_symbol_provider: nil,
|
|
65
|
+
completion_provider: nil, signature_help_provider: nil,
|
|
66
|
+
folding_range_provider: nil, selection_range_provider: nil,
|
|
67
|
+
project_context: nil)
|
|
68
|
+
@state = :uninitialized
|
|
69
|
+
@exit_code = nil
|
|
70
|
+
@buffer_table = buffer_table
|
|
71
|
+
@publisher = publisher
|
|
72
|
+
@hover_provider = hover_provider
|
|
73
|
+
@document_symbol_provider = document_symbol_provider
|
|
74
|
+
@completion_provider = completion_provider
|
|
75
|
+
@signature_help_provider = signature_help_provider
|
|
76
|
+
@folding_range_provider = folding_range_provider
|
|
77
|
+
@selection_range_provider = selection_range_provider
|
|
78
|
+
@project_context = project_context
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# @return [Boolean] true once the client has called `exit` and
|
|
82
|
+
# the server has set its terminal exit code. The CLI loop
|
|
83
|
+
# reads this between dispatches to know when to stop.
|
|
84
|
+
def exited?
|
|
85
|
+
@state == :exited
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Routes one LSP method call.
|
|
89
|
+
#
|
|
90
|
+
# @param method [String] the LSP method name (e.g. "initialize").
|
|
91
|
+
# @param params [Hash, nil] the LSP `params` payload (Hash for
|
|
92
|
+
# request / notification methods; nil for the empty case).
|
|
93
|
+
# @return [Hash, nil] one of:
|
|
94
|
+
# - the response result Hash for request methods,
|
|
95
|
+
# - nil for notification methods,
|
|
96
|
+
# - { error: { code:, message: } } for state / shape errors.
|
|
97
|
+
def dispatch(method, params = nil) # rubocop:disable Metrics/CyclomaticComplexity
|
|
98
|
+
return state_violation_response(method) unless method_allowed_in_state?(method)
|
|
99
|
+
|
|
100
|
+
case method
|
|
101
|
+
when "initialize" then handle_initialize(params)
|
|
102
|
+
when "initialized" then handle_initialized
|
|
103
|
+
when "shutdown" then handle_shutdown
|
|
104
|
+
when "exit" then handle_exit
|
|
105
|
+
when "textDocument/didOpen" then handle_did_open(params)
|
|
106
|
+
when "textDocument/didChange" then handle_did_change(params)
|
|
107
|
+
when "textDocument/didClose" then handle_did_close(params)
|
|
108
|
+
when "textDocument/hover" then handle_hover(params)
|
|
109
|
+
when "textDocument/documentSymbol" then handle_document_symbol(params)
|
|
110
|
+
when "textDocument/completion" then handle_completion(params)
|
|
111
|
+
when "textDocument/signatureHelp" then handle_signature_help(params)
|
|
112
|
+
when "textDocument/foldingRange" then handle_folding_range(params)
|
|
113
|
+
when "textDocument/selectionRange" then handle_selection_range(params)
|
|
114
|
+
when "workspace/didChangeWatchedFiles" then handle_did_change_watched_files(params)
|
|
115
|
+
when "workspace/didChangeConfiguration" then handle_did_change_configuration(params)
|
|
116
|
+
else
|
|
117
|
+
method_not_found(method)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def method_allowed_in_state?(method)
|
|
124
|
+
case @state
|
|
125
|
+
when :uninitialized then PRE_INITIALIZE_METHODS.include?(method)
|
|
126
|
+
when :initialized then method != "initialize"
|
|
127
|
+
when :shutdown then method == "exit"
|
|
128
|
+
when :exited then false
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def state_violation_response(method)
|
|
133
|
+
case @state
|
|
134
|
+
when :uninitialized
|
|
135
|
+
rpc_error(
|
|
136
|
+
ERROR_SERVER_NOT_INITIALIZED,
|
|
137
|
+
"method #{method.inspect} requires `initialize` first"
|
|
138
|
+
)
|
|
139
|
+
when :initialized
|
|
140
|
+
rpc_error(
|
|
141
|
+
ERROR_INVALID_REQUEST,
|
|
142
|
+
"method #{method.inspect} is not valid after `initialize` has succeeded"
|
|
143
|
+
)
|
|
144
|
+
when :shutdown
|
|
145
|
+
rpc_error(
|
|
146
|
+
ERROR_INVALID_REQUEST_AFTER_SHUTDOWN,
|
|
147
|
+
"method #{method.inspect} is not valid after `shutdown`; only `exit` is accepted"
|
|
148
|
+
)
|
|
149
|
+
when :exited
|
|
150
|
+
rpc_error(ERROR_INVALID_REQUEST, "server has exited")
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Per LSP spec § "Server lifecycle / initialize": the server
|
|
155
|
+
# responds with its capabilities. Each later slice extends
|
|
156
|
+
# `advertised_capabilities` with the handler it wires;
|
|
157
|
+
# clients asking for unadvertised methods get `MethodNotFound`.
|
|
158
|
+
def handle_initialize(_params)
|
|
159
|
+
@state = :initialized
|
|
160
|
+
{
|
|
161
|
+
capabilities: advertised_capabilities,
|
|
162
|
+
serverInfo: {
|
|
163
|
+
name: "rigor-lsp",
|
|
164
|
+
version: Rigor::VERSION
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def advertised_capabilities
|
|
170
|
+
caps = {
|
|
171
|
+
textDocumentSync: {
|
|
172
|
+
openClose: true,
|
|
173
|
+
change: TEXT_DOCUMENT_SYNC_FULL
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
caps[:hoverProvider] = true if @hover_provider
|
|
177
|
+
caps[:documentSymbolProvider] = true if @document_symbol_provider
|
|
178
|
+
if @completion_provider
|
|
179
|
+
caps[:completionProvider] = {
|
|
180
|
+
# `.` for method completion; `:` for constant-path
|
|
181
|
+
# completion (slice 6). The server detects which form
|
|
182
|
+
# by looking one character back when `:` triggers.
|
|
183
|
+
triggerCharacters: [".", ":"],
|
|
184
|
+
# v1 eager — full payload returned on first request.
|
|
185
|
+
# Resolve becomes relevant if large enumerations
|
|
186
|
+
# (Object descendants) become noticeable.
|
|
187
|
+
resolveProvider: false
|
|
188
|
+
}
|
|
189
|
+
end
|
|
190
|
+
if @signature_help_provider
|
|
191
|
+
caps[:signatureHelpProvider] = {
|
|
192
|
+
# `(` opens the argument list; `,` advances to the
|
|
193
|
+
# next argument. Editors retrigger on both.
|
|
194
|
+
triggerCharacters: ["(", ","]
|
|
195
|
+
}
|
|
196
|
+
end
|
|
197
|
+
caps[:foldingRangeProvider] = true if @folding_range_provider
|
|
198
|
+
caps[:selectionRangeProvider] = true if @selection_range_provider
|
|
199
|
+
caps
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# `initialized` is a notification — no response body. Slice 7
|
|
203
|
+
# will hook this to register `workspace/didChangeWatchedFiles`
|
|
204
|
+
# if the client advertised the capability.
|
|
205
|
+
def handle_initialized
|
|
206
|
+
nil
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def handle_shutdown
|
|
210
|
+
@state = :shutdown
|
|
211
|
+
# Drop any in-flight debounced publishes so they don't
|
|
212
|
+
# fire after the client has stopped listening.
|
|
213
|
+
@publisher&.cancel_pending
|
|
214
|
+
nil
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def handle_exit
|
|
218
|
+
@exit_code = @state == :shutdown ? 0 : 1
|
|
219
|
+
@state = :exited
|
|
220
|
+
nil
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# textDocument/didOpen notification. Per LSP spec § the
|
|
224
|
+
# `textDocument` payload carries `uri`, `languageId`,
|
|
225
|
+
# `version`, and the full initial `text`. Triggers a
|
|
226
|
+
# `publishDiagnostics` push when a publisher is wired.
|
|
227
|
+
def handle_did_open(params)
|
|
228
|
+
doc = params.fetch(:textDocument)
|
|
229
|
+
uri = doc.fetch(:uri)
|
|
230
|
+
@buffer_table.open(
|
|
231
|
+
uri: uri,
|
|
232
|
+
bytes: doc.fetch(:text),
|
|
233
|
+
version: doc.fetch(:version)
|
|
234
|
+
)
|
|
235
|
+
@publisher&.publish_for(uri)
|
|
236
|
+
nil
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# textDocument/didChange under FULL sync. Each `contentChanges`
|
|
240
|
+
# entry carries only `{ text: }`; the LAST entry is the new
|
|
241
|
+
# full document text. Per LSP spec § "FULL sync" the array
|
|
242
|
+
# MUST be exactly one entry in practice — we still take
|
|
243
|
+
# `.last` defensively for clients that pad. Triggers
|
|
244
|
+
# `publishDiagnostics` afterwards.
|
|
245
|
+
def handle_did_change(params)
|
|
246
|
+
doc = params.fetch(:textDocument)
|
|
247
|
+
changes = params.fetch(:contentChanges)
|
|
248
|
+
return nil if changes.empty?
|
|
249
|
+
|
|
250
|
+
uri = doc.fetch(:uri)
|
|
251
|
+
@buffer_table.change(
|
|
252
|
+
uri: uri,
|
|
253
|
+
bytes: changes.last.fetch(:text),
|
|
254
|
+
version: doc.fetch(:version)
|
|
255
|
+
)
|
|
256
|
+
@publisher&.publish_for(uri)
|
|
257
|
+
nil
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# textDocument/hover REQUEST. Slice 5 returns either a
|
|
261
|
+
# `Hover` payload (markdown contents wrapping type +
|
|
262
|
+
# erased-RBS info) or nil when no expression is at the
|
|
263
|
+
# queried position. Nil maps to `result: null` per LSP
|
|
264
|
+
# spec; clients suppress the popup. Returns
|
|
265
|
+
# `MethodNotFound` when no hover_provider is wired (slice
|
|
266
|
+
# 1-4 behaviour).
|
|
267
|
+
def handle_hover(params)
|
|
268
|
+
return method_not_found("textDocument/hover") unless @hover_provider
|
|
269
|
+
|
|
270
|
+
doc = params.fetch(:textDocument)
|
|
271
|
+
pos = params.fetch(:position)
|
|
272
|
+
@hover_provider.provide(
|
|
273
|
+
uri: doc.fetch(:uri),
|
|
274
|
+
line: pos.fetch(:line),
|
|
275
|
+
character: pos.fetch(:character)
|
|
276
|
+
)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# workspace/didChangeWatchedFiles NOTIFICATION. Invalidates
|
|
280
|
+
# the ProjectContext so cached pre-pass / Environment is
|
|
281
|
+
# rebuilt on the next request. Slice 7's floor: any watched
|
|
282
|
+
# file change triggers a full context rebuild. Per-file
|
|
283
|
+
# surgical invalidation (per design doc § "Project context
|
|
284
|
+
# refresh") is a follow-up; this is the LSP-correct floor.
|
|
285
|
+
def handle_did_change_watched_files(_params)
|
|
286
|
+
@project_context&.invalidate!
|
|
287
|
+
nil
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# workspace/didChangeConfiguration NOTIFICATION. The payload
|
|
291
|
+
# shape is client-specific; v1 ignores the payload and
|
|
292
|
+
# invalidates the context so the next read picks up any
|
|
293
|
+
# external config changes (.rigor.yml / Gemfile.lock / etc).
|
|
294
|
+
def handle_did_change_configuration(_params)
|
|
295
|
+
@project_context&.invalidate!
|
|
296
|
+
nil
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# textDocument/selectionRange REQUEST. Routes to the
|
|
300
|
+
# selection-range provider when wired; `MethodNotFound`
|
|
301
|
+
# otherwise.
|
|
302
|
+
def handle_selection_range(params)
|
|
303
|
+
return method_not_found("textDocument/selectionRange") unless @selection_range_provider
|
|
304
|
+
|
|
305
|
+
doc = params.fetch(:textDocument)
|
|
306
|
+
positions = params.fetch(:positions)
|
|
307
|
+
@selection_range_provider.provide(doc.fetch(:uri), positions)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# textDocument/foldingRange REQUEST. Routes to the
|
|
311
|
+
# folding-range provider when wired; `MethodNotFound`
|
|
312
|
+
# otherwise.
|
|
313
|
+
def handle_folding_range(params)
|
|
314
|
+
return method_not_found("textDocument/foldingRange") unless @folding_range_provider
|
|
315
|
+
|
|
316
|
+
doc = params.fetch(:textDocument)
|
|
317
|
+
@folding_range_provider.provide(doc.fetch(:uri))
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# textDocument/signatureHelp REQUEST. Routes to the
|
|
321
|
+
# signature-help provider when wired; `MethodNotFound`
|
|
322
|
+
# otherwise.
|
|
323
|
+
def handle_signature_help(params)
|
|
324
|
+
return method_not_found("textDocument/signatureHelp") unless @signature_help_provider
|
|
325
|
+
|
|
326
|
+
doc = params.fetch(:textDocument)
|
|
327
|
+
pos = params.fetch(:position)
|
|
328
|
+
context = params[:context]
|
|
329
|
+
@signature_help_provider.provide(
|
|
330
|
+
uri: doc.fetch(:uri),
|
|
331
|
+
line: pos.fetch(:line),
|
|
332
|
+
character: pos.fetch(:character),
|
|
333
|
+
context: context
|
|
334
|
+
)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# textDocument/completion REQUEST. Routes to the completion
|
|
338
|
+
# provider when wired; `MethodNotFound` otherwise.
|
|
339
|
+
def handle_completion(params)
|
|
340
|
+
return method_not_found("textDocument/completion") unless @completion_provider
|
|
341
|
+
|
|
342
|
+
doc = params.fetch(:textDocument)
|
|
343
|
+
pos = params.fetch(:position)
|
|
344
|
+
context = params[:context] || {}
|
|
345
|
+
@completion_provider.provide(
|
|
346
|
+
uri: doc.fetch(:uri),
|
|
347
|
+
line: pos.fetch(:line),
|
|
348
|
+
character: pos.fetch(:character),
|
|
349
|
+
trigger_character: context[:triggerCharacter]
|
|
350
|
+
)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# textDocument/documentSymbol REQUEST. Returns the
|
|
354
|
+
# `DocumentSymbol[]` outline for the buffer at the requested
|
|
355
|
+
# URI. Returns `MethodNotFound` when no provider is wired.
|
|
356
|
+
def handle_document_symbol(params)
|
|
357
|
+
return method_not_found("textDocument/documentSymbol") unless @document_symbol_provider
|
|
358
|
+
|
|
359
|
+
doc = params.fetch(:textDocument)
|
|
360
|
+
@document_symbol_provider.provide(doc.fetch(:uri))
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# textDocument/didClose. Drops the buffer table entry AND
|
|
364
|
+
# publishes an empty diagnostic set so clients clear inline
|
|
365
|
+
# markers — per LSP spec § "publishDiagnostics" the standard
|
|
366
|
+
# way to indicate "no diagnostics remain for this URI".
|
|
367
|
+
def handle_did_close(params)
|
|
368
|
+
doc = params.fetch(:textDocument)
|
|
369
|
+
uri = doc.fetch(:uri)
|
|
370
|
+
@buffer_table.close(uri: uri)
|
|
371
|
+
@publisher&.publish_empty(uri)
|
|
372
|
+
nil
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def method_not_found(method)
|
|
376
|
+
rpc_error(ERROR_METHOD_NOT_FOUND, "method not found: #{method.inspect}")
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def rpc_error(code, message)
|
|
380
|
+
{ error: { code: code, message: message } }
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
end
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "uri"
|
|
6
|
+
require_relative "../environment"
|
|
7
|
+
require_relative "../reflection"
|
|
8
|
+
require_relative "../scope"
|
|
9
|
+
require_relative "../source/node_locator"
|
|
10
|
+
require_relative "../inference/scope_indexer"
|
|
11
|
+
require_relative "../type/nominal"
|
|
12
|
+
require_relative "../type/singleton"
|
|
13
|
+
require_relative "../type/constant"
|
|
14
|
+
require_relative "../type/refined"
|
|
15
|
+
require_relative "../type/difference"
|
|
16
|
+
require_relative "../type/tuple"
|
|
17
|
+
require_relative "../type/hash_shape"
|
|
18
|
+
|
|
19
|
+
module Rigor
|
|
20
|
+
module LanguageServer
|
|
21
|
+
# Answers `textDocument/signatureHelp` requests. When the user
|
|
22
|
+
# types `(` inside a method call (`obj.foo(|`) editors fire
|
|
23
|
+
# `signatureHelp` to show the method's parameter signature
|
|
24
|
+
# inline. The provider parses the buffer, locates the enclosing
|
|
25
|
+
# `CallNode`, infers the receiver's type, and returns the
|
|
26
|
+
# method's first overload as a `SignatureInformation`.
|
|
27
|
+
#
|
|
28
|
+
# Slice C1 (this commit) ships:
|
|
29
|
+
# - Sentinel patching for `obj.foo(|` so Prism's parse
|
|
30
|
+
# succeeds (mirrors `CompletionProvider`'s slice B4 pattern).
|
|
31
|
+
# - First-overload signature only.
|
|
32
|
+
# - Active parameter = comma count before cursor.
|
|
33
|
+
#
|
|
34
|
+
# Multi-overload presentation + `documentation` field +
|
|
35
|
+
# active-parameter override per overload land in follow-up
|
|
36
|
+
# slices (queued in the design doc § "Out of scope for v2").
|
|
37
|
+
class SignatureHelpProvider
|
|
38
|
+
ARG_SENTINEL = "__rigor_lsp_arg_sentinel__"
|
|
39
|
+
private_constant :ARG_SENTINEL
|
|
40
|
+
|
|
41
|
+
def initialize(buffer_table:, project_context:)
|
|
42
|
+
@buffer_table = buffer_table
|
|
43
|
+
@project_context = project_context
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [Hash, nil] LSP `SignatureHelp` payload or nil
|
|
47
|
+
# when the cursor isn't inside a resolvable method call.
|
|
48
|
+
def provide(uri:, line:, character:, context: nil)
|
|
49
|
+
_ = context # Trigger info accepted but not routed in v1.
|
|
50
|
+
path = Uri.to_path(uri)
|
|
51
|
+
return nil if path.nil?
|
|
52
|
+
|
|
53
|
+
entry = @buffer_table[uri]
|
|
54
|
+
return nil if entry.nil?
|
|
55
|
+
|
|
56
|
+
bytes, locate_at = parse_attempt_bytes(entry.bytes, line, character)
|
|
57
|
+
parse_result = Prism.parse(bytes, filepath: path,
|
|
58
|
+
version: @project_context.configuration.target_ruby)
|
|
59
|
+
return nil unless parse_result.errors.empty?
|
|
60
|
+
|
|
61
|
+
cursor_offset = byte_offset_for(bytes, locate_at[0], locate_at[1])
|
|
62
|
+
return nil if cursor_offset.nil?
|
|
63
|
+
|
|
64
|
+
call_node = enclosing_call_for_offset(parse_result.value, cursor_offset)
|
|
65
|
+
return nil if call_node.nil?
|
|
66
|
+
|
|
67
|
+
build_signature(call_node, parse_result.value, path, bytes, locate_at[0], locate_at[1])
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def parse_attempt_bytes(original_bytes, line, character)
|
|
73
|
+
return [original_bytes, [line, character]] if Prism.parse(original_bytes).errors.empty?
|
|
74
|
+
|
|
75
|
+
patch_with_arg_sentinel(original_bytes, line, character)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Mid-edit buffer at `obj.foo(|` or `obj.foo(1,|`: truncate
|
|
79
|
+
# everything from the cursor onwards and append `SENTINEL)`
|
|
80
|
+
# so the call is syntactically complete. The truncation is
|
|
81
|
+
# aggressive — signatureHelp only cares about the enclosing
|
|
82
|
+
# call's signature; downstream content (closing parens,
|
|
83
|
+
# subsequent statements) is irrelevant.
|
|
84
|
+
def patch_with_arg_sentinel(original_bytes, line, character)
|
|
85
|
+
prefix_offset = byte_offset_for(original_bytes, line, character)
|
|
86
|
+
return [original_bytes, [line, character]] if prefix_offset.nil?
|
|
87
|
+
|
|
88
|
+
prefix = original_bytes.byteslice(0, prefix_offset)
|
|
89
|
+
stripped = prefix.rstrip
|
|
90
|
+
return [original_bytes, [line, character]] unless stripped.end_with?("(") || stripped.end_with?(",")
|
|
91
|
+
|
|
92
|
+
patched = "#{prefix}#{ARG_SENTINEL})\n"
|
|
93
|
+
[patched, [line, character]]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Walks the AST for the smallest CallNode whose `arguments`
|
|
97
|
+
# location encloses the cursor offset. Prism doesn't expose
|
|
98
|
+
# parent pointers, so NodeLocator's leaf-returning shape
|
|
99
|
+
# isn't enough; we re-walk. For LSP usage this is cheap —
|
|
100
|
+
# the buffer is parsed once per request.
|
|
101
|
+
def enclosing_call_for_offset(root, cursor_offset)
|
|
102
|
+
result = nil
|
|
103
|
+
walk = lambda do |n|
|
|
104
|
+
next unless n.is_a?(Prism::Node)
|
|
105
|
+
|
|
106
|
+
if n.is_a?(Prism::CallNode) && n.arguments && offset_in?(n.arguments.location, cursor_offset)
|
|
107
|
+
result = n # Innermost-wins because we keep walking children.
|
|
108
|
+
end
|
|
109
|
+
n.compact_child_nodes.each(&walk)
|
|
110
|
+
end
|
|
111
|
+
walk.call(root)
|
|
112
|
+
result
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def offset_in?(location, offset)
|
|
116
|
+
offset.between?(location.start_offset, location.end_offset)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def build_signature(call_node, root, path, bytes, line, character)
|
|
120
|
+
scope_index = build_scope_index(root, path)
|
|
121
|
+
receiver_node = call_node.receiver
|
|
122
|
+
return nil if receiver_node.nil?
|
|
123
|
+
|
|
124
|
+
receiver_type = scope_index[receiver_node].type_of(receiver_node)
|
|
125
|
+
definition = lookup_method(receiver_type, call_node.name, scope_index[receiver_node])
|
|
126
|
+
return nil if definition.nil? || definition.method_types.empty?
|
|
127
|
+
|
|
128
|
+
active_param = active_parameter_index(call_node, bytes, line, character)
|
|
129
|
+
doc = rbs_documentation(definition)
|
|
130
|
+
signatures = definition.method_types.map do |method_type|
|
|
131
|
+
info = {
|
|
132
|
+
label: "#{call_node.name}#{method_type}",
|
|
133
|
+
parameters: parameter_information(method_type)
|
|
134
|
+
}
|
|
135
|
+
info[:documentation] = { kind: "markdown", value: doc } if doc
|
|
136
|
+
info
|
|
137
|
+
end
|
|
138
|
+
{
|
|
139
|
+
signatures: signatures,
|
|
140
|
+
# `activeSignature` is the index editors highlight by
|
|
141
|
+
# default. Slice C2 picks the first overload uniformly;
|
|
142
|
+
# a future slice could choose the overload that best
|
|
143
|
+
# matches the current argument shape.
|
|
144
|
+
activeSignature: 0,
|
|
145
|
+
activeParameter: active_param
|
|
146
|
+
}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Builds the LSP `ParameterInformation[]` for a method type's
|
|
150
|
+
# parameter list. Each entry's `label` is a STRING form
|
|
151
|
+
# (e.g. `"::int width"`, `"?::string pad_string"`); LSP's
|
|
152
|
+
# offset-tuple form (`[start, end]`) for in-signature
|
|
153
|
+
# highlighting is queued. Order matches `activeParameter`'s
|
|
154
|
+
# index expectation: required positionals, then optionals,
|
|
155
|
+
# then rest, then trailing, then required keywords, then
|
|
156
|
+
# optional keywords, then rest keywords.
|
|
157
|
+
def parameter_information(method_type) # rubocop:disable Metrics/AbcSize
|
|
158
|
+
func = method_type.type
|
|
159
|
+
return [] unless func.respond_to?(:required_positionals)
|
|
160
|
+
|
|
161
|
+
params = func.required_positionals.map { |p| { label: format_param(p) } }
|
|
162
|
+
func.optional_positionals.each { |p| params << { label: "?#{format_param(p)}" } }
|
|
163
|
+
params << { label: "*#{format_param(func.rest_positionals)}" } if func.rest_positionals
|
|
164
|
+
func.trailing_positionals.each { |p| params << { label: format_param(p) } }
|
|
165
|
+
func.required_keywords.each { |name, p| params << { label: "#{name}: #{format_param(p)}" } }
|
|
166
|
+
func.optional_keywords.each { |name, p| params << { label: "?#{name}: #{format_param(p)}" } }
|
|
167
|
+
params << { label: "**#{format_param(func.rest_keywords)}" } if func.rest_keywords
|
|
168
|
+
params
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def format_param(param)
|
|
172
|
+
param.name ? "#{param.type} #{param.name}" : param.type.to_s
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Identical contract to HoverRenderer#rbs_documentation —
|
|
176
|
+
# surfaces the method's RBS comment text or nil. Kept inline
|
|
177
|
+
# rather than extracted to a shared mixin because the two
|
|
178
|
+
# call sites are small and the shape may diverge (signatureHelp
|
|
179
|
+
# might want per-parameter docs split out; hover wants the
|
|
180
|
+
# full paragraph).
|
|
181
|
+
def rbs_documentation(definition)
|
|
182
|
+
comments = definition.respond_to?(:comments) ? definition.comments : nil
|
|
183
|
+
return nil if comments.nil? || comments.empty?
|
|
184
|
+
|
|
185
|
+
text = comments.map(&:string).join("\n\n").strip
|
|
186
|
+
text.empty? ? nil : text
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def lookup_method(receiver_type, method_name, scope)
|
|
190
|
+
case receiver_type
|
|
191
|
+
when Type::Singleton
|
|
192
|
+
Reflection.singleton_method_definition(receiver_type.class_name, method_name, scope: scope)
|
|
193
|
+
when Type::Refined, Type::Difference
|
|
194
|
+
lookup_method(receiver_type.base, method_name, scope)
|
|
195
|
+
else
|
|
196
|
+
class_name = nominal_class_name(receiver_type)
|
|
197
|
+
return nil if class_name.nil?
|
|
198
|
+
|
|
199
|
+
Reflection.instance_method_definition(class_name, method_name, scope: scope)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Mirrors CompletionProvider's receiver-type mapping. Tuple →
|
|
204
|
+
# Array, HashShape → Hash, Refined / Difference unwrap to
|
|
205
|
+
# their base (handled in `lookup_method` above for clarity).
|
|
206
|
+
def nominal_class_name(type)
|
|
207
|
+
case type
|
|
208
|
+
when Type::Nominal then type.class_name
|
|
209
|
+
when Type::Constant then type.value.class.name
|
|
210
|
+
when Type::Tuple then "Array"
|
|
211
|
+
when Type::HashShape then "Hash"
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def build_scope_index(root, _path)
|
|
216
|
+
scope = Scope.empty(environment: @project_context.environment)
|
|
217
|
+
Inference::ScopeIndexer.index(root, default_scope: scope)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Counts commas in the buffer between the call's opening `(`
|
|
221
|
+
# and the cursor position. Cursor on the first argument → 0;
|
|
222
|
+
# after one comma → 1; etc. Bounded by the call's arguments
|
|
223
|
+
# location so commas in nested expressions don't bleed in.
|
|
224
|
+
def active_parameter_index(call_node, bytes, line, character)
|
|
225
|
+
return 0 if call_node.arguments.nil?
|
|
226
|
+
|
|
227
|
+
cursor_offset = byte_offset_for(bytes, line, character)
|
|
228
|
+
args_loc = call_node.arguments.location
|
|
229
|
+
return 0 if args_loc.nil? || cursor_offset.nil?
|
|
230
|
+
|
|
231
|
+
scan_start = args_loc.start_offset
|
|
232
|
+
scan_end = [cursor_offset, args_loc.end_offset].min
|
|
233
|
+
return 0 if scan_end <= scan_start
|
|
234
|
+
|
|
235
|
+
bytes.byteslice(scan_start, scan_end - scan_start).count(",")
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def byte_offset_for(bytes, line, character)
|
|
239
|
+
offset = 0
|
|
240
|
+
bytes.each_line.with_index do |line_bytes, idx|
|
|
241
|
+
return offset + character if idx == line
|
|
242
|
+
|
|
243
|
+
offset += line_bytes.bytesize
|
|
244
|
+
end
|
|
245
|
+
nil
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module LanguageServer
|
|
5
|
+
# Wraps the LSP gem's `Io::Writer` with a Mutex so concurrent
|
|
6
|
+
# writes (the dispatch loop's response writes + the Debouncer's
|
|
7
|
+
# async `publishDiagnostics` writes) don't interleave on the
|
|
8
|
+
# shared STDOUT.
|
|
9
|
+
#
|
|
10
|
+
# Pass-through proxy: `#write(message)` is the only call site
|
|
11
|
+
# the rest of the LSP uses; `#close` is forwarded for
|
|
12
|
+
# completeness.
|
|
13
|
+
class SynchronizedWriter
|
|
14
|
+
def initialize(inner)
|
|
15
|
+
@inner = inner
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def write(message)
|
|
20
|
+
@mutex.synchronize { @inner.write(message) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def close
|
|
24
|
+
@mutex.synchronize { @inner.close }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|