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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +36 -50
  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/project_scan.rb +39 -0
  9. data/lib/rigor/analysis/runner.rb +309 -22
  10. data/lib/rigor/analysis/worker_session.rb +14 -2
  11. data/lib/rigor/builtins/hkt_builtins.rb +342 -0
  12. data/lib/rigor/builtins/static_return_refinements.rb +120 -0
  13. data/lib/rigor/cache/store.rb +33 -3
  14. data/lib/rigor/cli/lsp_command.rb +129 -0
  15. data/lib/rigor/cli/type_of_command.rb +44 -5
  16. data/lib/rigor/cli.rb +74 -12
  17. data/lib/rigor/configuration.rb +38 -2
  18. data/lib/rigor/environment/hkt_registry_holder.rb +33 -0
  19. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  20. data/lib/rigor/environment/rbs_loader.rb +45 -2
  21. data/lib/rigor/environment/reporters.rb +40 -0
  22. data/lib/rigor/environment.rb +106 -9
  23. data/lib/rigor/inference/acceptance.rb +48 -3
  24. data/lib/rigor/inference/expression_typer.rb +47 -0
  25. data/lib/rigor/inference/hkt_body.rb +171 -0
  26. data/lib/rigor/inference/hkt_body_parser.rb +363 -0
  27. data/lib/rigor/inference/hkt_reducer.rb +256 -0
  28. data/lib/rigor/inference/hkt_registry.rb +223 -0
  29. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +125 -30
  30. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +87 -0
  31. data/lib/rigor/inference/method_dispatcher.rb +154 -3
  32. data/lib/rigor/inference/project_patched_methods.rb +70 -0
  33. data/lib/rigor/inference/project_patched_scanner.rb +210 -0
  34. data/lib/rigor/inference/scope_indexer.rb +156 -12
  35. data/lib/rigor/inference/statement_evaluator.rb +106 -6
  36. data/lib/rigor/inference/synthetic_method_scanner.rb +94 -16
  37. data/lib/rigor/language_server/buffer_table.rb +63 -0
  38. data/lib/rigor/language_server/completion_provider.rb +438 -0
  39. data/lib/rigor/language_server/debouncer.rb +86 -0
  40. data/lib/rigor/language_server/diagnostic_publisher.rb +167 -0
  41. data/lib/rigor/language_server/document_symbol_provider.rb +142 -0
  42. data/lib/rigor/language_server/folding_range_provider.rb +75 -0
  43. data/lib/rigor/language_server/hover_provider.rb +74 -0
  44. data/lib/rigor/language_server/hover_renderer.rb +312 -0
  45. data/lib/rigor/language_server/loop.rb +71 -0
  46. data/lib/rigor/language_server/project_context.rb +145 -0
  47. data/lib/rigor/language_server/selection_range_provider.rb +93 -0
  48. data/lib/rigor/language_server/server.rb +384 -0
  49. data/lib/rigor/language_server/signature_help_provider.rb +249 -0
  50. data/lib/rigor/language_server/synchronized_writer.rb +28 -0
  51. data/lib/rigor/language_server/uri.rb +40 -0
  52. data/lib/rigor/language_server.rb +29 -0
  53. data/lib/rigor/plugin/base.rb +63 -0
  54. data/lib/rigor/plugin/macro/heredoc_template.rb +125 -11
  55. data/lib/rigor/plugin/manifest.rb +54 -7
  56. data/lib/rigor/plugin/registry.rb +19 -0
  57. data/lib/rigor/rbs_extended/hkt_directives.rb +326 -0
  58. data/lib/rigor/rbs_extended.rb +82 -2
  59. data/lib/rigor/sig_gen/generator.rb +12 -3
  60. data/lib/rigor/type/app.rb +107 -0
  61. data/lib/rigor/type.rb +1 -0
  62. data/lib/rigor/version.rb +1 -1
  63. data/sig/rigor/environment.rbs +8 -4
  64. data/sig/rigor/inference.rbs +2 -0
  65. data/sig/rigor.rbs +3 -1
  66. 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