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.
Files changed (107) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +69 -56
  3. data/lib/rigor/analysis/buffer_binding.rb +36 -0
  4. data/lib/rigor/analysis/check_rules.rb +11 -1
  5. data/lib/rigor/analysis/dependency_source_inference/index.rb +14 -1
  6. data/lib/rigor/analysis/dependency_source_inference/return_type_heuristic.rb +105 -0
  7. data/lib/rigor/analysis/dependency_source_inference/walker.rb +32 -12
  8. data/lib/rigor/analysis/fact_store.rb +15 -3
  9. data/lib/rigor/analysis/project_scan.rb +39 -0
  10. data/lib/rigor/analysis/result.rb +11 -3
  11. data/lib/rigor/analysis/run_stats.rb +193 -0
  12. data/lib/rigor/analysis/runner.rb +681 -19
  13. data/lib/rigor/analysis/worker_session.rb +339 -0
  14. data/lib/rigor/builtins/hkt_builtins.rb +342 -0
  15. data/lib/rigor/builtins/imported_refinements.rb +6 -2
  16. data/lib/rigor/builtins/regex_refinement.rb +17 -12
  17. data/lib/rigor/builtins/static_return_refinements.rb +120 -0
  18. data/lib/rigor/cache/rbs_descriptor.rb +3 -1
  19. data/lib/rigor/cache/store.rb +72 -9
  20. data/lib/rigor/cli/lsp_command.rb +129 -0
  21. data/lib/rigor/cli/type_of_command.rb +44 -5
  22. data/lib/rigor/cli.rb +122 -10
  23. data/lib/rigor/configuration.rb +168 -7
  24. data/lib/rigor/environment/bundle_sig_discovery.rb +198 -0
  25. data/lib/rigor/environment/class_registry.rb +12 -3
  26. data/lib/rigor/environment/hkt_registry_holder.rb +33 -0
  27. data/lib/rigor/environment/lockfile_resolver.rb +125 -0
  28. data/lib/rigor/environment/rbs_collection_discovery.rb +126 -0
  29. data/lib/rigor/environment/rbs_coverage_report.rb +112 -0
  30. data/lib/rigor/environment/rbs_loader.rb +238 -7
  31. data/lib/rigor/environment/reflection.rb +152 -0
  32. data/lib/rigor/environment/reporters.rb +40 -0
  33. data/lib/rigor/environment.rb +179 -10
  34. data/lib/rigor/inference/acceptance.rb +83 -4
  35. data/lib/rigor/inference/builtins/method_catalog.rb +12 -5
  36. data/lib/rigor/inference/builtins/numeric_catalog.rb +15 -4
  37. data/lib/rigor/inference/expression_typer.rb +59 -2
  38. data/lib/rigor/inference/hkt_body.rb +171 -0
  39. data/lib/rigor/inference/hkt_body_parser.rb +363 -0
  40. data/lib/rigor/inference/hkt_reducer.rb +256 -0
  41. data/lib/rigor/inference/hkt_registry.rb +223 -0
  42. data/lib/rigor/inference/macro_block_self_type.rb +96 -0
  43. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +29 -29
  44. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -4
  45. data/lib/rigor/inference/method_dispatcher/method_folding.rb +18 -1
  46. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +126 -31
  47. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +87 -0
  48. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +46 -40
  49. data/lib/rigor/inference/method_dispatcher.rb +282 -6
  50. data/lib/rigor/inference/method_parameter_binder.rb +21 -11
  51. data/lib/rigor/inference/narrowing.rb +127 -8
  52. data/lib/rigor/inference/project_patched_methods.rb +70 -0
  53. data/lib/rigor/inference/project_patched_scanner.rb +210 -0
  54. data/lib/rigor/inference/scope_indexer.rb +156 -12
  55. data/lib/rigor/inference/statement_evaluator.rb +106 -6
  56. data/lib/rigor/inference/synthetic_method.rb +86 -0
  57. data/lib/rigor/inference/synthetic_method_index.rb +82 -0
  58. data/lib/rigor/inference/synthetic_method_scanner.rb +599 -0
  59. data/lib/rigor/language_server/buffer_table.rb +63 -0
  60. data/lib/rigor/language_server/completion_provider.rb +438 -0
  61. data/lib/rigor/language_server/debouncer.rb +86 -0
  62. data/lib/rigor/language_server/diagnostic_publisher.rb +167 -0
  63. data/lib/rigor/language_server/document_symbol_provider.rb +142 -0
  64. data/lib/rigor/language_server/folding_range_provider.rb +75 -0
  65. data/lib/rigor/language_server/hover_provider.rb +74 -0
  66. data/lib/rigor/language_server/hover_renderer.rb +312 -0
  67. data/lib/rigor/language_server/loop.rb +71 -0
  68. data/lib/rigor/language_server/project_context.rb +145 -0
  69. data/lib/rigor/language_server/selection_range_provider.rb +93 -0
  70. data/lib/rigor/language_server/server.rb +384 -0
  71. data/lib/rigor/language_server/signature_help_provider.rb +249 -0
  72. data/lib/rigor/language_server/synchronized_writer.rb +28 -0
  73. data/lib/rigor/language_server/uri.rb +40 -0
  74. data/lib/rigor/language_server.rb +29 -0
  75. data/lib/rigor/plugin/base.rb +63 -0
  76. data/lib/rigor/plugin/blueprint.rb +60 -0
  77. data/lib/rigor/plugin/loader.rb +3 -1
  78. data/lib/rigor/plugin/macro/block_as_method.rb +131 -0
  79. data/lib/rigor/plugin/macro/external_file.rb +143 -0
  80. data/lib/rigor/plugin/macro/heredoc_template.rb +315 -0
  81. data/lib/rigor/plugin/macro/trait_registry.rb +198 -0
  82. data/lib/rigor/plugin/macro.rb +31 -0
  83. data/lib/rigor/plugin/manifest.rb +127 -9
  84. data/lib/rigor/plugin/registry.rb +51 -2
  85. data/lib/rigor/plugin.rb +1 -0
  86. data/lib/rigor/rbs_extended/hkt_directives.rb +326 -0
  87. data/lib/rigor/rbs_extended.rb +82 -2
  88. data/lib/rigor/sig_gen/generator.rb +12 -3
  89. data/lib/rigor/trinary.rb +15 -11
  90. data/lib/rigor/type/app.rb +107 -0
  91. data/lib/rigor/type/bot.rb +6 -3
  92. data/lib/rigor/type/combinator.rb +12 -1
  93. data/lib/rigor/type/integer_range.rb +7 -7
  94. data/lib/rigor/type/refined.rb +18 -12
  95. data/lib/rigor/type/top.rb +4 -3
  96. data/lib/rigor/type.rb +1 -0
  97. data/lib/rigor/type_node/generic.rb +7 -1
  98. data/lib/rigor/type_node/identifier.rb +9 -1
  99. data/lib/rigor/type_node/string_literal.rb +4 -1
  100. data/lib/rigor/version.rb +1 -1
  101. data/sig/rigor/environment.rbs +11 -4
  102. data/sig/rigor/inference.rbs +2 -0
  103. data/sig/rigor/plugin/blueprint.rbs +7 -0
  104. data/sig/rigor/plugin/manifest.rbs +1 -1
  105. data/sig/rigor/plugin/registry.rbs +14 -1
  106. data/sig/rigor.rbs +37 -2
  107. 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