rigortype 0.1.5 → 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 +36 -50
- 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/project_scan.rb +39 -0
- data/lib/rigor/analysis/runner.rb +309 -22
- data/lib/rigor/analysis/worker_session.rb +14 -2
- data/lib/rigor/builtins/hkt_builtins.rb +342 -0
- data/lib/rigor/builtins/static_return_refinements.rb +120 -0
- data/lib/rigor/cache/store.rb +33 -3
- data/lib/rigor/cli/lsp_command.rb +129 -0
- data/lib/rigor/cli/type_of_command.rb +44 -5
- data/lib/rigor/cli.rb +74 -12
- data/lib/rigor/configuration.rb +38 -2
- data/lib/rigor/environment/hkt_registry_holder.rb +33 -0
- data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
- data/lib/rigor/environment/rbs_loader.rb +45 -2
- data/lib/rigor/environment/reporters.rb +40 -0
- data/lib/rigor/environment.rb +106 -9
- data/lib/rigor/inference/acceptance.rb +48 -3
- data/lib/rigor/inference/expression_typer.rb +47 -0
- 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/method_dispatcher/overload_selector.rb +125 -30
- data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +87 -0
- data/lib/rigor/inference/method_dispatcher.rb +154 -3
- 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_scanner.rb +94 -16
- 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/macro/heredoc_template.rb +125 -11
- data/lib/rigor/plugin/manifest.rb +54 -7
- data/lib/rigor/plugin/registry.rb +19 -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/type/app.rb +107 -0
- data/lib/rigor/type.rb +1 -0
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/environment.rbs +8 -4
- data/sig/rigor/inference.rbs +2 -0
- data/sig/rigor.rbs +3 -1
- metadata +54 -1
|
@@ -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
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tempfile"
|
|
4
|
+
|
|
5
|
+
require_relative "uri"
|
|
6
|
+
require_relative "../analysis/runner"
|
|
7
|
+
require_relative "../analysis/buffer_binding"
|
|
8
|
+
|
|
9
|
+
module Rigor
|
|
10
|
+
module LanguageServer
|
|
11
|
+
# Converts buffer state into `textDocument/publishDiagnostics`
|
|
12
|
+
# notifications. Owns the Rigor `Analysis::Runner` orchestration
|
|
13
|
+
# for the per-buffer single-file scope path that editor mode v1
|
|
14
|
+
# already supports — every `publish_for(uri)` call materialises
|
|
15
|
+
# a `BufferBinding` from the BufferTable entry, runs the Runner,
|
|
16
|
+
# and pushes the resulting LSP `Diagnostic[]` through the writer.
|
|
17
|
+
#
|
|
18
|
+
# Slice 4 is synchronous: each call blocks until analysis
|
|
19
|
+
# completes (typically 100-300ms warm). Debouncing (200ms
|
|
20
|
+
# quiet-time before publish) and Ractor-pool dispatch are
|
|
21
|
+
# queued for slice 4b / slice 8 respectively.
|
|
22
|
+
class DiagnosticPublisher
|
|
23
|
+
# Maps Rigor severity symbols to LSP DiagnosticSeverity
|
|
24
|
+
# integers per spec § "Diagnostic":
|
|
25
|
+
# 1 = Error, 2 = Warning, 3 = Information, 4 = Hint.
|
|
26
|
+
SEVERITY_MAP = {
|
|
27
|
+
error: 1,
|
|
28
|
+
warning: 2,
|
|
29
|
+
info: 3,
|
|
30
|
+
hint: 4
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
# @param debouncer [Rigor::LanguageServer::Debouncer, nil]
|
|
34
|
+
# when present, `publish_for` schedules its work through
|
|
35
|
+
# the debouncer (cancels prior pending task for the same
|
|
36
|
+
# URI, fires after `debounce_seconds` quiet-time). Nil
|
|
37
|
+
# keeps the slice 4-7 synchronous behaviour — primarily
|
|
38
|
+
# useful for specs.
|
|
39
|
+
# @param debounce_seconds [Numeric] quiet-time before the
|
|
40
|
+
# debounced publish fires. 0 with a debouncer means
|
|
41
|
+
# "schedule on next-tick" (still async); without a
|
|
42
|
+
# debouncer the value is unused.
|
|
43
|
+
def initialize(writer:, buffer_table:, project_context:,
|
|
44
|
+
debouncer: nil, debounce_seconds: 0.2)
|
|
45
|
+
@writer = writer
|
|
46
|
+
@buffer_table = buffer_table
|
|
47
|
+
@project_context = project_context
|
|
48
|
+
@debouncer = debouncer
|
|
49
|
+
@debounce_seconds = debounce_seconds
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Run analysis for the buffer at `uri` (looked up in the
|
|
53
|
+
# BufferTable) and push a `textDocument/publishDiagnostics`
|
|
54
|
+
# notification. No-op when the URI isn't a `file://` form or
|
|
55
|
+
# the buffer isn't currently open. When a Debouncer is wired,
|
|
56
|
+
# the analysis is scheduled async per the configured
|
|
57
|
+
# `debounce_seconds`; otherwise it runs inline.
|
|
58
|
+
def publish_for(uri)
|
|
59
|
+
path = Uri.to_path(uri)
|
|
60
|
+
return if path.nil?
|
|
61
|
+
|
|
62
|
+
if @debouncer
|
|
63
|
+
@debouncer.schedule(uri, delay: @debounce_seconds) { run_and_notify(uri, path) }
|
|
64
|
+
else
|
|
65
|
+
run_and_notify(uri, path)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Publishes an EMPTY diagnostic array for `uri`. The LSP-spec
|
|
70
|
+
# idiom for "clear inline markers" — called from `didClose`
|
|
71
|
+
# so clients drop stale highlights when the user closes a
|
|
72
|
+
# buffer.
|
|
73
|
+
def publish_empty(uri)
|
|
74
|
+
notify(uri, [])
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Cancels every in-flight debounced task. Called from
|
|
78
|
+
# `Server#handle_shutdown` so pending publishes don't fire
|
|
79
|
+
# against a closed STDOUT.
|
|
80
|
+
def cancel_pending
|
|
81
|
+
@debouncer&.cancel_all
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def run_and_notify(uri, path)
|
|
87
|
+
entry = @buffer_table[uri]
|
|
88
|
+
# The buffer may have been closed during the debounce
|
|
89
|
+
# window — drop the publish; the empty notification from
|
|
90
|
+
# didClose already cleared the markers.
|
|
91
|
+
return if entry.nil?
|
|
92
|
+
|
|
93
|
+
diagnostics = run_analysis(path: path, bytes: entry.bytes)
|
|
94
|
+
notify(uri, diagnostics)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Runs `Analysis::Runner` with a `BufferBinding` so the buffer
|
|
98
|
+
# bytes (instead of the on-disk file) drive the parse. The
|
|
99
|
+
# `Rigor::Analysis::ProjectScan` cached on the ProjectContext
|
|
100
|
+
# is passed through `prebuilt:` so plugin `#prepare`, the
|
|
101
|
+
# dependency-source walker, and the synthetic-method /
|
|
102
|
+
# project-patched scanners do not re-run per publish. The
|
|
103
|
+
# snapshot rebuilds only when `ProjectContext#invalidate!`
|
|
104
|
+
# fires (watched-file or configuration change). Returns
|
|
105
|
+
# the LSP-shaped Diagnostic Array, ready to serialize into
|
|
106
|
+
# the notification's `params.diagnostics` field.
|
|
107
|
+
def run_analysis(path:, bytes:)
|
|
108
|
+
with_tempfile(bytes) do |tmp|
|
|
109
|
+
binding = Analysis::BufferBinding.new(logical_path: path, physical_path: tmp.path)
|
|
110
|
+
runner = Analysis::Runner.new(
|
|
111
|
+
configuration: @project_context.configuration,
|
|
112
|
+
cache_store: @project_context.cache_store,
|
|
113
|
+
collect_stats: false,
|
|
114
|
+
buffer: binding,
|
|
115
|
+
prebuilt: @project_context.project_scan,
|
|
116
|
+
environment: @project_context.environment
|
|
117
|
+
)
|
|
118
|
+
result = runner.run([path])
|
|
119
|
+
result.diagnostics.filter_map { |diagnostic| to_lsp_diagnostic(diagnostic, path) }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def with_tempfile(bytes)
|
|
124
|
+
tmp = Tempfile.new(["rigor-lsp-buffer-", ".rb"])
|
|
125
|
+
tmp.write(bytes)
|
|
126
|
+
tmp.flush
|
|
127
|
+
yield tmp
|
|
128
|
+
ensure
|
|
129
|
+
tmp&.close
|
|
130
|
+
tmp&.unlink
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# @return [Hash, nil] the LSP `Diagnostic` Hash, or nil to
|
|
134
|
+
# skip diagnostics outside the buffer's own path (e.g.
|
|
135
|
+
# `.rigor.yml`-anchored info diagnostics get filtered —
|
|
136
|
+
# they belong to the project, not the buffer).
|
|
137
|
+
def to_lsp_diagnostic(diagnostic, buffer_path)
|
|
138
|
+
return nil if diagnostic.path != buffer_path
|
|
139
|
+
|
|
140
|
+
# Rigor uses 1-based line + 1-based byte column; LSP uses
|
|
141
|
+
# 0-based line + 0-based UTF-16 code unit. UTF-16 conversion
|
|
142
|
+
# is queued (design doc § "Open questions"); v1 emits byte
|
|
143
|
+
# columns which are correct for ASCII source.
|
|
144
|
+
line = (diagnostic.line - 1).clamp(0, Float::INFINITY).to_i
|
|
145
|
+
character = (diagnostic.column - 1).clamp(0, Float::INFINITY).to_i
|
|
146
|
+
|
|
147
|
+
{
|
|
148
|
+
range: {
|
|
149
|
+
start: { line: line, character: character },
|
|
150
|
+
end: { line: line, character: character }
|
|
151
|
+
},
|
|
152
|
+
severity: SEVERITY_MAP.fetch(diagnostic.severity, 3),
|
|
153
|
+
code: diagnostic.rule,
|
|
154
|
+
source: "rigor",
|
|
155
|
+
message: diagnostic.message
|
|
156
|
+
}.compact
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def notify(uri, diagnostics)
|
|
160
|
+
@writer.write(
|
|
161
|
+
method: "textDocument/publishDiagnostics",
|
|
162
|
+
params: { uri: uri, diagnostics: diagnostics }
|
|
163
|
+
)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|