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,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module LanguageServer
5
+ # Per-session virtual file table. The LSP server maintains the
6
+ # canonical view of every open buffer here; analysis (slice 4+)
7
+ # reads from this table instead of disk so in-flight edits are
8
+ # reflected immediately.
9
+ #
10
+ # Keyed by `DocumentUri` (LSP `file://...` URIs). v1 ships
11
+ # FULL text sync (LSP `TextDocumentSyncKind::Full = 1`) so each
12
+ # `didChange` carries the entire buffer text — there's no
13
+ # incremental edit application yet. Incremental sync is slice
14
+ # 10 (deferred per the design doc).
15
+ class BufferTable
16
+ # @!attribute uri [String] the LSP DocumentUri (e.g. `file:///abs/path/lib/foo.rb`).
17
+ # @!attribute bytes [String] the current full text of the buffer.
18
+ # @!attribute version [Integer] the monotonically increasing LSP version number.
19
+ Entry = Data.define(:uri, :bytes, :version)
20
+
21
+ def initialize
22
+ @entries = {}
23
+ end
24
+
25
+ # Records a `textDocument/didOpen` event. Replaces any
26
+ # existing entry (LSP clients may re-open a previously closed
27
+ # URI; the new version is authoritative).
28
+ def open(uri:, bytes:, version:)
29
+ @entries[uri] = Entry.new(uri: uri, bytes: bytes, version: version)
30
+ end
31
+
32
+ # Records a `textDocument/didChange` event under FULL sync.
33
+ # The full new buffer text replaces the entry. If the client
34
+ # sends a `didChange` for a URI that was never opened (spec
35
+ # violation), the entry is still created — defensive.
36
+ def change(uri:, bytes:, version:)
37
+ @entries[uri] = Entry.new(uri: uri, bytes: bytes, version: version)
38
+ end
39
+
40
+ # Records a `textDocument/didClose` event. The entry is
41
+ # removed. Subsequent reads via `#[]` return nil.
42
+ def close(uri:)
43
+ @entries.delete(uri)
44
+ end
45
+
46
+ def [](uri)
47
+ @entries[uri]
48
+ end
49
+
50
+ def open?(uri)
51
+ @entries.key?(uri)
52
+ end
53
+
54
+ def size
55
+ @entries.size
56
+ end
57
+
58
+ def uris
59
+ @entries.keys
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,438 @@
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/union"
15
+ require_relative "../type/intersection"
16
+ require_relative "../type/refined"
17
+ require_relative "../type/difference"
18
+ require_relative "../type/tuple"
19
+ require_relative "../type/hash_shape"
20
+
21
+ module Rigor
22
+ module LanguageServer
23
+ # Answers `textDocument/completion` requests. v1 (slice 5)
24
+ # ships method completion for `obj.|`: when the cursor sits on
25
+ # a `CallNode` with a known-type receiver, the provider
26
+ # enumerates the receiver's RBS-known methods and returns each
27
+ # as an LSP `CompletionItem`.
28
+ #
29
+ # Constant-path completion (slice 6), Union / Intersection /
30
+ # Refined / Shape receiver handling (slice 7), and parse-recovery
31
+ # fallback for malformed buffers (slice 8) extend this v1 floor.
32
+ #
33
+ # LSP `CompletionItemKind` values used:
34
+ # - 2 = Method
35
+ #
36
+ # Slice 6 will add 7 (Class), 9 (Module), 21 (Constant).
37
+ class CompletionProvider # rubocop:disable Metrics/ClassLength
38
+ KIND_METHOD = 2
39
+ KIND_FIELD = 5
40
+ KIND_CLASS = 7
41
+ KIND_MODULE = 9
42
+ KIND_CONSTANT = 21
43
+
44
+ def initialize(buffer_table:, project_context:)
45
+ @buffer_table = buffer_table
46
+ @project_context = project_context
47
+ end
48
+
49
+ # @return [Array<Hash>, nil] LSP `CompletionItem[]` or nil
50
+ # when the cursor isn't at a position the provider can
51
+ # enumerate completions for. Returning nil maps to
52
+ # `result: null` per the LSP spec — clients treat it as
53
+ # "no completions available," distinct from `[]` which
54
+ # means "we tried and got nothing".
55
+ def provide(uri:, line:, character:, trigger_character: nil)
56
+ _ = trigger_character # Trigger info logged-not-routed in v1.
57
+ path = Uri.to_path(uri)
58
+ return nil if path.nil?
59
+
60
+ entry = @buffer_table[uri]
61
+ return nil if entry.nil?
62
+
63
+ # Slice B4 — parse recovery. The common mid-edit buffer
64
+ # (`obj.` / `Foo::`) doesn't parse cleanly; try inserting
65
+ # a sentinel name at the cursor before falling through.
66
+ bytes_to_parse, locate_at = parse_attempt_bytes(entry.bytes, line, character)
67
+ parse_result = Prism.parse(bytes_to_parse, filepath: path,
68
+ version: @project_context.configuration.target_ruby)
69
+ return nil unless parse_result.errors.empty?
70
+
71
+ # Rigor's NodeLocator uses 1-based line / column; LSP uses 0-based.
72
+ node = locate_node(source: bytes_to_parse, root: parse_result.value,
73
+ line: locate_at[0] + 1, character: locate_at[1] + 1)
74
+ return nil if node.nil?
75
+
76
+ case node
77
+ when Prism::CallNode
78
+ # `hash[:|` patches to `hash[:KEY_SENTINEL]`, an index
79
+ # access call whose argument is the sentinel symbol;
80
+ # NodeLocator at the sentinel returns the inner CallNode
81
+ # for `[]`. Hash-key completion wins when the call is an
82
+ # index access on a HashShape carrier; otherwise fall
83
+ # through to method completion.
84
+ hash_key_completion_for(node, parse_result.value, path) ||
85
+ method_completion_for(node, parse_result.value, path)
86
+ when Prism::SymbolNode
87
+ # The sentinel symbol's NodeLocator hit; walk up via the
88
+ # AST to find the enclosing `[]` call.
89
+ hash_key_completion_for_symbol(node, parse_result.value, path)
90
+ when Prism::ConstantPathNode
91
+ constant_path_completion_for(node, path)
92
+ end
93
+ end
94
+
95
+ private
96
+
97
+ # Slice B4 — parse recovery. Returns `[bytes, [line, character]]`:
98
+ # the source to feed Prism plus the cursor position the
99
+ # caller should locate against. When the original buffer
100
+ # parses cleanly we return it verbatim; when it doesn't AND
101
+ # the cursor sits at a mid-edit `.` / `::` trigger, we
102
+ # splice a sentinel identifier into the source so Prism's
103
+ # AST captures the receiver / parent constant cleanly.
104
+ #
105
+ # The sentinel is a syntactically-unique identifier the
106
+ # NodeLocator can find without ambiguity. We choose lower /
107
+ # upper case based on whether the trigger was `.` (method
108
+ # name — lowercase identifier) or `::` (constant path —
109
+ # uppercase identifier).
110
+ # Sentinels need to be parseable as their target shape:
111
+ # method sentinel is a lower-snake identifier; constant
112
+ # sentinel MUST start with an uppercase letter (Ruby
113
+ # constants reject `_`-leading names — `Process::__Foo`
114
+ # parses as a method call, not a constant path).
115
+ SENTINEL_METHOD = "__rigor_lsp_sentinel__"
116
+ SENTINEL_CONSTANT = "RigorLspSentinelXyz"
117
+ SENTINEL_HASH_KEY = "__rigor_lsp_key__"
118
+ private_constant :SENTINEL_METHOD, :SENTINEL_CONSTANT, :SENTINEL_HASH_KEY
119
+
120
+ def parse_attempt_bytes(original_bytes, line, character)
121
+ # Cheap probe: try original first. Most editor sessions
122
+ # call completion in mid-keystroke where parse fails, but
123
+ # some land on a clean buffer (e.g. the trigger fires
124
+ # right after the user picked an item).
125
+ if Prism.parse(original_bytes).errors.empty?
126
+ [original_bytes, [line, character]]
127
+ else
128
+ patch_with_sentinel(original_bytes, line, character)
129
+ end
130
+ end
131
+
132
+ def patch_with_sentinel(original_bytes, line, character)
133
+ lines = original_bytes.lines
134
+ return [original_bytes, [line, character]] if line >= lines.size
135
+
136
+ target = lines[line]
137
+ prefix = target.byteslice(0, character) || ""
138
+ suffix_offset = [character, target.bytesize].min
139
+ suffix = target.byteslice(suffix_offset, target.bytesize - suffix_offset) || ""
140
+
141
+ sentinel = sentinel_for_prefix(prefix)
142
+ return [original_bytes, [line, character]] if sentinel.nil?
143
+
144
+ lines[line] = "#{prefix}#{sentinel}#{suffix}"
145
+ # The cursor stays at the same column — Prism's AST now
146
+ # has a node whose name occupies [character, character +
147
+ # sentinel.size). NodeLocator at that column returns the
148
+ # enclosing call / path.
149
+ [lines.join, [line, character]]
150
+ end
151
+
152
+ def sentinel_for_prefix(prefix)
153
+ # Strip trailing whitespace before checking for the
154
+ # trigger character; users often type the dot then a
155
+ # space mid-edit.
156
+ stripped = prefix.rstrip
157
+ # `[:` covers `hash[:|` symbol-key access — splice both
158
+ # the key name AND the closing `]` so Prism gets a
159
+ # complete index expression. The closing bracket is part
160
+ # of the sentinel string for this case (vs the bare
161
+ # identifier sentinels for `.` / `::`).
162
+ return "#{SENTINEL_HASH_KEY}]" if stripped.end_with?("[:")
163
+ return SENTINEL_METHOD if stripped.end_with?(".")
164
+ return SENTINEL_CONSTANT if stripped.end_with?("::")
165
+
166
+ nil
167
+ end
168
+
169
+ def method_completion_for(call_node, root, path)
170
+ receiver_node = call_node.receiver
171
+ return nil if receiver_node.nil? # implicit-self — slice-3 territory of completion
172
+
173
+ index = build_scope_index(root, path)
174
+ receiver_scope = index[receiver_node]
175
+ receiver_type = receiver_scope.type_of(receiver_node)
176
+ method_completions(receiver_type, receiver_scope)
177
+ end
178
+
179
+ # Slice D1 — `hash[:|` hash-key completion. Triggers when
180
+ # the cursor is on the synthetic key inside a `[]` call AND
181
+ # the receiver's inferred type is a `Type::HashShape`. Returns
182
+ # nil for any other receiver carrier so the dispatcher falls
183
+ # through to method completion (which still surfaces Hash's
184
+ # methods for genuine method calls on a hash receiver).
185
+ def hash_key_completion_for(call_node, root, path)
186
+ return nil unless call_node.name == :[]
187
+
188
+ receiver_node = call_node.receiver
189
+ return nil if receiver_node.nil?
190
+
191
+ index = build_scope_index(root, path)
192
+ receiver_type = index[receiver_node].type_of(receiver_node)
193
+ return nil unless receiver_type.is_a?(Type::HashShape)
194
+
195
+ hash_key_items(receiver_type)
196
+ end
197
+
198
+ # When NodeLocator returns the sentinel SymbolNode directly
199
+ # (rather than the enclosing CallNode), walk up the AST for
200
+ # the smallest `[]` call containing the symbol's location.
201
+ # Re-walks the root once; cheap for LSP-sized buffers.
202
+ def hash_key_completion_for_symbol(symbol_node, root, path)
203
+ call_node = enclosing_index_call(root, symbol_node)
204
+ return nil if call_node.nil?
205
+
206
+ hash_key_completion_for(call_node, root, path)
207
+ end
208
+
209
+ def enclosing_index_call(root, symbol_node)
210
+ symbol_offset = symbol_node.location.start_offset
211
+ result = nil
212
+ walk = lambda do |n|
213
+ next unless n.is_a?(Prism::Node)
214
+
215
+ if n.is_a?(Prism::CallNode) && n.name == :[] && n.location &&
216
+ n.location.start_offset <= symbol_offset && symbol_offset <= n.location.end_offset
217
+ result = n
218
+ end
219
+ n.compact_child_nodes.each(&walk)
220
+ end
221
+ walk.call(root)
222
+ result
223
+ end
224
+
225
+ def hash_key_items(hash_shape)
226
+ hash_shape.pairs.keys.map do |key|
227
+ label = key.inspect # `:foo` for symbols, `"bar"` for strings
228
+ {
229
+ label: label,
230
+ kind: KIND_FIELD,
231
+ detail: "key of HashShape",
232
+ insertText: label,
233
+ filterText: label,
234
+ sortText: "0_#{label}"
235
+ }
236
+ end
237
+ end
238
+
239
+ # Slice B2 — `Foo::|` constant-path completion. The cursor
240
+ # sits on a `ConstantPathNode` whose `parent` resolves to a
241
+ # class / module FQN; we enumerate every known class whose
242
+ # name is an immediate child of that parent. Top-level
243
+ # constants (`::Foo`) and parent-less paths are not yet
244
+ # supported (queued for slice 6 follow-up).
245
+ def constant_path_completion_for(const_path_node, path)
246
+ parent_fqn = parent_fqn_of(const_path_node)
247
+ return nil if parent_fqn.nil?
248
+
249
+ scope = base_scope(path)
250
+ children = enumerate_constant_children(parent_fqn, scope)
251
+ return nil if children.empty?
252
+
253
+ children.map { |child| constant_completion_item(parent_fqn, child) }
254
+ end
255
+
256
+ def parent_fqn_of(const_path_node)
257
+ # ConstantPathNode#parent is the LHS of the `::`. For
258
+ # `Foo::Bar` it's the ConstantReadNode for `Foo`; for
259
+ # `Foo::Bar::Baz` it's a ConstantPathNode. We render
260
+ # either to the dotted FQN string.
261
+ parent = const_path_node.parent
262
+ return nil if parent.nil?
263
+
264
+ qualified_name_of(parent)
265
+ end
266
+
267
+ def qualified_name_of(node)
268
+ case node
269
+ when Prism::ConstantReadNode
270
+ node.name.to_s
271
+ when Prism::ConstantPathNode
272
+ parent = qualified_name_of(node.parent) if node.parent
273
+ parent.nil? ? node.name.to_s : "#{parent}::#{node.name}"
274
+ end
275
+ end
276
+
277
+ # Walks `RbsLoader#known_class_names_set` for entries whose
278
+ # FQN is `parent_fqn::<one segment>` — the immediate children.
279
+ # Deeper descendants are filtered out so the popup shows
280
+ # only the next-level constants the user can directly write.
281
+ # `known_class_names_set` is private on RbsLoader per the
282
+ # type-system's internal API discipline; the LSP layer is a
283
+ # trusted internal consumer and `send` is the documented
284
+ # escape hatch (same pattern as `Type::Refined#canonical_name`
285
+ # in slice A4).
286
+ def enumerate_constant_children(parent_fqn, scope)
287
+ loader = scope.environment.rbs_loader
288
+ return [] if loader.nil?
289
+
290
+ names = loader.send(:known_class_names_set)
291
+ # RBS canonical names carry a leading `::` (so the table
292
+ # holds `::Process::Status` etc.). Match against both
293
+ # forms so the prefix walk works regardless of which form
294
+ # the caller passes.
295
+ prefix = "::#{parent_fqn}::"
296
+ names.filter_map do |fqn|
297
+ next nil unless fqn.start_with?(prefix)
298
+
299
+ tail = fqn.delete_prefix(prefix)
300
+ # Only immediate children — no `::` in the tail.
301
+ next nil if tail.empty? || tail.include?("::")
302
+
303
+ tail
304
+ end.uniq.sort
305
+ end
306
+
307
+ def constant_completion_item(parent_fqn, child_name)
308
+ {
309
+ label: child_name,
310
+ kind: KIND_CLASS, # heuristic; slice-7 follow-up may distinguish Module / Constant
311
+ detail: "#{parent_fqn}::#{child_name}",
312
+ insertText: child_name,
313
+ filterText: child_name,
314
+ sortText: "0_#{child_name}"
315
+ }
316
+ end
317
+
318
+ def base_scope(_path)
319
+ Scope.empty(environment: @project_context.environment)
320
+ end
321
+
322
+ def locate_node(source:, root:, line:, character:)
323
+ Source::NodeLocator.at_position(source: source, root: root, line: line, column: character)
324
+ rescue Source::NodeLocator::OutOfRangeError
325
+ nil
326
+ end
327
+
328
+ def build_scope_index(root, _path)
329
+ scope = Scope.empty(environment: @project_context.environment)
330
+ Inference::ScopeIndexer.index(root, default_scope: scope)
331
+ end
332
+
333
+ # Returns an Array<Hash> of LSP `CompletionItem`s for every
334
+ # public method callable on the receiver. Slice B3 extends
335
+ # the slice-B1 floor with:
336
+ # - `Type::Refined` / `Type::Difference` — enumerate the
337
+ # underlying nominal (refinement narrows the value set,
338
+ # not the method set).
339
+ # - `Type::Tuple` / `Type::HashShape` — enumerate the
340
+ # nominal ancestor (`Array` / `Hash`); element-type-aware
341
+ # completion is queued.
342
+ # - `Type::Union` — intersection of methods on each member
343
+ # (only methods guaranteed to dispatch on every union case).
344
+ # Conservative default per design doc § "Union receiver
345
+ # completion".
346
+ # - `Type::Intersection` — union of methods on each member
347
+ # (anything callable on at least one member).
348
+ def method_completions(receiver_type, scope)
349
+ method_set, kind = enumerate_method_set(receiver_type, scope)
350
+ return nil if method_set.nil? || method_set.empty?
351
+
352
+ method_set.filter_map do |name, method|
353
+ next nil unless method.public?
354
+
355
+ completion_item(name: name, method: method, kind: kind)
356
+ end
357
+ end
358
+
359
+ # Returns `[{Symbol => RBS::Definition::Method}, :instance | :singleton]`
360
+ # for the receiver. Composite carriers (Union / Intersection /
361
+ # Refined / shape carriers) reduce to instance-method
362
+ # enumeration; the receiver-class label that lands in each
363
+ # CompletionItem's `detail` still comes from each method's
364
+ # own `defs.first.implemented_in`, so the rendered prefix
365
+ # stays accurate per-method.
366
+ def enumerate_method_set(receiver_type, scope)
367
+ case receiver_type
368
+ when Type::Singleton
369
+ [Reflection.singleton_definition(receiver_type.class_name, scope: scope)&.methods, :singleton]
370
+ when Type::Union
371
+ [intersect_member_methods(receiver_type.members, scope), :instance]
372
+ when Type::Intersection
373
+ [union_member_methods(receiver_type.members, scope), :instance]
374
+ when Type::Refined, Type::Difference
375
+ enumerate_method_set(receiver_type.base, scope)
376
+ when Type::Tuple
377
+ [Reflection.instance_definition("Array", scope: scope)&.methods, :instance]
378
+ when Type::HashShape
379
+ [Reflection.instance_definition("Hash", scope: scope)&.methods, :instance]
380
+ else
381
+ class_name = nominal_class_name(receiver_type)
382
+ return [nil, nil] if class_name.nil?
383
+
384
+ [Reflection.instance_definition(class_name, scope: scope)&.methods, :instance]
385
+ end
386
+ end
387
+
388
+ # Union receiver — keep only methods present in EVERY
389
+ # member's set. Conservative semantically (every method
390
+ # returned is callable on every member) and prevents
391
+ # `obj.upcase` from appearing on a `String | Integer`
392
+ # union where only one side answers `upcase`.
393
+ def intersect_member_methods(members, scope)
394
+ member_sets = members.filter_map { |m| enumerate_method_set(m, scope).first }
395
+ return nil if member_sets.empty?
396
+
397
+ common_names = member_sets.map(&:keys).reduce(:&)
398
+ member_sets.first.slice(*common_names)
399
+ end
400
+
401
+ # Intersection receiver — accumulate every method declared on
402
+ # ANY member. A value of type `A & B` is callable through both
403
+ # interfaces; the completion popup MAY show either's methods.
404
+ def union_member_methods(members, scope)
405
+ members.filter_map { |m| enumerate_method_set(m, scope).first }.reduce({}, :merge)
406
+ end
407
+
408
+ def nominal_class_name(type)
409
+ case type
410
+ when Type::Nominal then type.class_name
411
+ when Type::Constant then type.value.class.name
412
+ end
413
+ end
414
+
415
+ def completion_item(name:, method:, kind:)
416
+ label = name.to_s
417
+ method_type = method.method_types.first
418
+ signature = method_type ? method_type.to_s : "(unknown)"
419
+ sep = kind == :singleton ? "." : "#"
420
+ receiver_name = method.defs.first&.implemented_in.to_s
421
+ detail = receiver_name.empty? ? signature : "#{receiver_name}#{sep}#{label}: #{signature}"
422
+
423
+ {
424
+ label: label,
425
+ kind: KIND_METHOD,
426
+ detail: detail,
427
+ insertText: label,
428
+ filterText: label,
429
+ # Inherited methods rank below own-class methods; the
430
+ # `defs.first.implemented_in` carries the declaring
431
+ # class. Inheritance-distance ranking is queued (design
432
+ # doc § "sortText").
433
+ sortText: "1_#{label}"
434
+ }
435
+ end
436
+ end
437
+ end
438
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module LanguageServer
5
+ # Per-key debouncer. The LSP uses this to defer
6
+ # `publishDiagnostics` until the user stops typing (200ms
7
+ # quiet-time floor per LSP UX conventions). Each
8
+ # `schedule(uri, delay:)` cancels the previous task for the
9
+ # same `uri` and queues a new one — only the LAST task in a
10
+ # burst actually runs.
11
+ #
12
+ # Threading model: each scheduled task runs in its own Thread
13
+ # so the dispatcher loop doesn't block. Concurrent writes to
14
+ # STDOUT from the Debouncer's threads + the main dispatch
15
+ # loop are serialised by `SynchronizedWriter`.
16
+ #
17
+ # Cancellation is cooperative: each task carries a
18
+ # `cancelled` flag; new schedules flip the prior task's flag
19
+ # and the prior thread skips the block on wake-up. This is
20
+ # safer than `Thread#kill` for in-flight Ruby code and good
21
+ # enough for the "drop stale debounce" use case.
22
+ class Debouncer
23
+ Task = Struct.new(:thread, :cancelled)
24
+
25
+ def initialize
26
+ @tasks = {}
27
+ @mutex = Mutex.new
28
+ end
29
+
30
+ # Schedule `block` to run after `delay` seconds, replacing
31
+ # any pending task for the same `key`. `delay: 0` makes the
32
+ # task fire immediately (still on its own thread); tests
33
+ # pair this with `#flush!` for deterministic assertions.
34
+ def schedule(key, delay:, &block)
35
+ task = Task.new(nil, false)
36
+
37
+ previous = @mutex.synchronize do
38
+ prev = @tasks[key]
39
+ @tasks[key] = task
40
+ prev
41
+ end
42
+ previous&.cancelled = true
43
+
44
+ task.thread = Thread.new do
45
+ sleep(delay) if delay.positive?
46
+ unless task.cancelled
47
+ begin
48
+ block.call
49
+ rescue StandardError => e
50
+ warn "Debouncer task #{key.inspect}: #{e.class}: #{e.message}"
51
+ end
52
+ end
53
+ @mutex.synchronize { @tasks.delete(key) if @tasks[key] == task }
54
+ end
55
+ nil
56
+ end
57
+
58
+ # Wait for every pending task to complete. Used by specs to
59
+ # synchronise with the async schedule; the production
60
+ # `shutdown` path uses `#cancel_all` instead.
61
+ def flush!
62
+ threads = @mutex.synchronize { @tasks.values.map(&:thread) }
63
+ threads.each do |thread|
64
+ thread.join
65
+ rescue StandardError
66
+ # Threads can die from raised exceptions; ignore.
67
+ end
68
+ end
69
+
70
+ # Cancel every pending task (sets the flag; the threads
71
+ # exit without running the block). Called on `shutdown` so
72
+ # in-flight publishes don't write to a closed STDOUT.
73
+ def cancel_all
74
+ @mutex.synchronize do
75
+ @tasks.each_value { |t| t.cancelled = true }
76
+ @tasks.clear
77
+ end
78
+ end
79
+
80
+ # @return [Integer] number of currently-pending tasks.
81
+ def pending_size
82
+ @mutex.synchronize { @tasks.size }
83
+ end
84
+ end
85
+ end
86
+ end