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,312 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../reflection"
4
+ require_relative "../type/nominal"
5
+ require_relative "../type/singleton"
6
+ require_relative "../type/constant"
7
+ require_relative "../type/refined"
8
+ require_relative "../type/difference"
9
+
10
+ module Rigor
11
+ module LanguageServer
12
+ # Builds the LSP `Hover.contents` markdown body. Dispatches on
13
+ # the hovered Prism node class so each shape (method call,
14
+ # constant, local, literal, …) gets the most relevant
15
+ # type-aware presentation.
16
+ #
17
+ # Slice A1 (this commit) ships:
18
+ # - default body bit-for-bit matching the LSP v1 slice 5
19
+ # output (`type:` / `erased:` / `node:`),
20
+ # - `Prism::CallNode` specialisation surfacing the receiver
21
+ # type + RBS-erased method signature.
22
+ #
23
+ # Slices A2-A4 extend the dispatch with constant /
24
+ # local / ivar / literal renderers per
25
+ # `docs/design/20260517-lsp-hover-completion.md`.
26
+ class HoverRenderer
27
+ # @param node_scope_lookup [#[]] node-to-scope table built
28
+ # by `ScopeIndexer.index`. The renderer indexes into it to
29
+ # retrieve the receiver's narrow scope when specialising on
30
+ # `CallNode`. The lookup never returns nil (the indexer's
31
+ # Hash carries `default_scope` as its default value), so
32
+ # the renderer trusts the lookup result.
33
+ def render(node:, type:, node_scope_lookup:)
34
+ body = render_body(node, type, node_scope_lookup)
35
+ result = { contents: { kind: "markdown", value: body } }
36
+ result[:range] = lsp_range_for(node) if node.respond_to?(:location) && node.location
37
+ result
38
+ end
39
+
40
+ private
41
+
42
+ # Converts a Prism `Location` to an LSP `Range` (0-based
43
+ # line, 0-based UTF-16-character column). UTF-16 conversion
44
+ # is queued — slice E1 emits byte columns which match for
45
+ # ASCII source; non-ASCII falls back gracefully because
46
+ # clients clamp out-of-range columns to the end of line.
47
+ def lsp_range_for(node)
48
+ loc = node.location
49
+ {
50
+ start: { line: loc.start_line - 1, character: loc.start_column },
51
+ end: { line: loc.end_line - 1, character: loc.end_column }
52
+ }
53
+ end
54
+
55
+ def render_body(node, type, node_scope_lookup)
56
+ case node
57
+ when Prism::CallNode
58
+ render_call(node, type, node_scope_lookup) || render_default(node, type)
59
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
60
+ render_constant(node, type, node_scope_lookup) || render_default(node, type)
61
+ when Prism::LocalVariableReadNode, Prism::LocalVariableWriteNode,
62
+ Prism::LocalVariableTargetNode
63
+ render_local(node, type)
64
+ when Prism::InstanceVariableReadNode, Prism::InstanceVariableWriteNode,
65
+ Prism::InstanceVariableTargetNode
66
+ render_ivar(node, type, node_scope_lookup)
67
+ when Prism::IntegerNode, Prism::FloatNode, Prism::RationalNode,
68
+ Prism::ImaginaryNode, Prism::StringNode, Prism::SymbolNode,
69
+ Prism::RegularExpressionNode, Prism::TrueNode, Prism::FalseNode,
70
+ Prism::NilNode, Prism::ArrayNode, Prism::HashNode
71
+ render_literal(node, type)
72
+ else
73
+ render_default(node, type)
74
+ end
75
+ end
76
+
77
+ # Renders a `CallNode` hover when the receiver's type can be
78
+ # mapped to a known RBS class and the method is declared
79
+ # there. Returns nil for shapes the slice-A1 floor doesn't
80
+ # handle (implicit `self`, Union / Refined / Shape receivers
81
+ # — those land in slices A2-A4 and slice 7); the caller falls
82
+ # back to the default body.
83
+ def render_call(call_node, return_type, node_scope_lookup)
84
+ receiver_node = call_node.receiver
85
+ return nil if receiver_node.nil?
86
+
87
+ receiver_scope = node_scope_lookup[receiver_node]
88
+ return nil if receiver_scope.nil?
89
+
90
+ receiver_type = receiver_scope.type_of(receiver_node)
91
+ definition, class_name, kind = lookup_method(receiver_type, call_node.name, receiver_scope)
92
+ return nil if definition.nil?
93
+
94
+ build_call_body(
95
+ class_name: class_name,
96
+ kind: kind,
97
+ method_name: call_node.name,
98
+ definition: definition,
99
+ return_type: return_type
100
+ )
101
+ end
102
+
103
+ # @return [[RBS::Definition::Method, String, Symbol], nil]
104
+ # the resolved method definition, the receiver class name,
105
+ # and the dispatch kind (`:instance` or `:singleton`); nil
106
+ # when the receiver shape isn't yet supported or the
107
+ # method doesn't resolve through the RBS env.
108
+ def lookup_method(receiver_type, method_name, scope)
109
+ case receiver_type
110
+ when Type::Singleton
111
+ definition = Reflection.singleton_method_definition(
112
+ receiver_type.class_name, method_name, scope: scope
113
+ )
114
+ [definition, receiver_type.class_name, :singleton]
115
+ else
116
+ class_name = nominal_class_name(receiver_type)
117
+ return [nil, nil, nil] if class_name.nil?
118
+
119
+ definition = Reflection.instance_method_definition(
120
+ class_name, method_name, scope: scope
121
+ )
122
+ [definition, class_name, :instance]
123
+ end
124
+ end
125
+
126
+ # Maps a receiver carrier to the underlying RBS class name
127
+ # for method lookup. v1 handles the two carriers that produce
128
+ # well-defined ".methods to enumerate" semantics: `Nominal[C]`
129
+ # (the canonical case) and `Constant<v>` (literal scalars,
130
+ # where the value's runtime class is the receiver). Other
131
+ # carriers fall through to the default hover; slice 7 of the
132
+ # design adds Union / Refined / Shape handling.
133
+ def nominal_class_name(type)
134
+ case type
135
+ when Type::Nominal then type.class_name
136
+ when Type::Constant then constant_to_class_name(type.value)
137
+ end
138
+ end
139
+
140
+ # `Type::Constant<value>`'s `value` is the literal Ruby
141
+ # object; map it to the RBS-canonical class name through
142
+ # `Object#class`. The cross-runtime mapping mirrors how the
143
+ # dispatcher already widens a literal to its nominal class
144
+ # at the call site — keep these in sync if a new literal
145
+ # carrier lands.
146
+ def constant_to_class_name(value)
147
+ value.class.name
148
+ end
149
+
150
+ def build_call_body(class_name:, kind:, method_name:, definition:, return_type:)
151
+ sep = kind == :singleton ? "." : "#"
152
+ body = +"```ruby\n"
153
+ body << "# Receiver\n"
154
+ body << "#{class_name}\n\n"
155
+ body << "# Method\n"
156
+ body << "#{class_name}#{sep}#{method_name}: #{first_method_type(definition)}\n\n"
157
+ body << "# Return\n"
158
+ body << "#{return_type.describe}\n"
159
+ body << "```"
160
+ if (doc = rbs_documentation(definition))
161
+ # Close the code fence first so the comment text renders
162
+ # as prose, then a fresh fenced block isn't necessary —
163
+ # plain markdown below the code reads better in clients.
164
+ body << "\n\n---\n\n#{doc}"
165
+ end
166
+ body
167
+ end
168
+
169
+ # Returns the concatenated text of every RBS comment attached
170
+ # to the method definition, or nil when no comments exist.
171
+ # `RBS::Definition::Method#comments` is an Array<AST::Comment>
172
+ # with each entry's `.string` carrying the raw text (newline-
173
+ # terminated `# foo` lines). Multiple comments are joined
174
+ # with a blank line so each upstream `# Foo bar` paragraph
175
+ # is preserved.
176
+ def rbs_documentation(definition)
177
+ comments = definition.respond_to?(:comments) ? definition.comments : nil
178
+ return nil if comments.nil? || comments.empty?
179
+
180
+ text = comments.map(&:string).join("\n\n").strip
181
+ text.empty? ? nil : text
182
+ end
183
+
184
+ # v1 surfaces the FIRST overload only. Multi-overload
185
+ # presentation is queued (design doc § "Out of scope for v2"
186
+ # — multi-overload signature display).
187
+ def first_method_type(definition)
188
+ method_type = definition.method_types.first
189
+ return "(unknown)" if method_type.nil?
190
+
191
+ method_type.to_s
192
+ end
193
+
194
+ # Specialises `ConstantReadNode` (`Foo`) and `ConstantPathNode`
195
+ # (`Foo::Bar`) when the inferred type is a `Type::Singleton`
196
+ # — i.e., the constant refers to a class / module. Returns nil
197
+ # for constants pointing at values (`FOO = 42`); those fall
198
+ # through to the literal-polish slice (A4).
199
+ def render_constant(node, type, node_scope_lookup)
200
+ return nil unless type.is_a?(Type::Singleton)
201
+
202
+ fqn = type.class_name
203
+ scope = node_scope_lookup[node]
204
+ location = defined_in(fqn, scope)
205
+
206
+ body = +"```ruby\n"
207
+ body << "# Constant\n#{fqn}\n\n"
208
+ body << "# Type\nsingleton(#{fqn})\n"
209
+ body << "\n# Defined in\n#{location}\n" if location
210
+ body << "```"
211
+ body
212
+ end
213
+
214
+ # Resolves the source-file location for a class FQN by reading
215
+ # the RBS loader's `class_decl_paths` table. Returns nil when
216
+ # the table doesn't carry attribution (cache-hit paths replace
217
+ # it with a sentinel, see `RunStats.attribution_available?`).
218
+ def defined_in(fqn, scope)
219
+ loader = scope&.environment&.rbs_loader
220
+ return nil if loader.nil?
221
+
222
+ path = loader.class_decl_paths[fqn]
223
+ return nil if path.nil? || path.empty?
224
+
225
+ path
226
+ end
227
+
228
+ # Specialises local-variable reads / writes / target nodes.
229
+ # Surfaces the variable name + narrowed type. "Bound at"
230
+ # source-line attribution is queued (Scope#locals tracks
231
+ # {name => Type} but not the binding location); when the
232
+ # ScopeIndexer grows a side-table for it, slice A3 follow-up
233
+ # can fill the row in.
234
+ def render_local(node, type)
235
+ body = +"```ruby\n"
236
+ body << "# Local\n#{node.name}\n\n"
237
+ body << "# Type\n#{type.describe}\n"
238
+ body << "```"
239
+ body
240
+ end
241
+
242
+ # Specialises instance-variable reads / writes / targets.
243
+ # Surfaces the ivar name + narrowed type + the enclosing
244
+ # class context derived from the scope's `self_type`. When
245
+ # the self_type isn't a Nominal (e.g., top-level main) the
246
+ # enclosing-class row is omitted.
247
+ def render_ivar(node, type, node_scope_lookup)
248
+ scope = node_scope_lookup[node]
249
+ body = +"```ruby\n"
250
+ body << "# Ivar\n#{node.name}\n\n"
251
+ body << "# Type\n#{type.describe}\n"
252
+ if scope && (enclosing = enclosing_class_for(scope))
253
+ body << "\n# In class\n#{enclosing}\n"
254
+ end
255
+ body << "```"
256
+ body
257
+ end
258
+
259
+ def enclosing_class_for(scope)
260
+ self_type = scope.self_type
261
+ # Both `Nominal[C]` and `Singleton[C]` carry `class_name`;
262
+ # we want the class label either way. Combined branch
263
+ # keeps the slice-A3 contract simple.
264
+ self_type.class_name if self_type.is_a?(Type::Nominal) || self_type.is_a?(Type::Singleton)
265
+ end
266
+
267
+ # Specialises literal-bearing nodes (Integer / Float / String /
268
+ # Symbol / Regex / true / false / nil / Array / Hash). Drops
269
+ # the slice-A1 `node:` debug row in favour of a cleaner
270
+ # `# Type` + `# Erased` framing, and surfaces the refinement
271
+ # / difference name when one is present. For Array / Hash
272
+ # the shape carriers (`Tuple<...>` / `HashShape<...>`) already
273
+ # describe element types, so the framing is identical to
274
+ # primitive literals.
275
+ def render_literal(_node, type)
276
+ body = +"```ruby\n"
277
+ body << "# Type\n#{type.describe}\n"
278
+ body << "\n# Erased\n#{type.erase_to_rbs}\n"
279
+ if (name = refinement_name_for(type))
280
+ body << "\n# Refinement\n#{name}\n"
281
+ end
282
+ body << "```"
283
+ body
284
+ end
285
+
286
+ # Surfaces the canonical kebab-case refinement name when the
287
+ # type is a `Refined` or `Difference` carrier with a
288
+ # registered canonical_name (e.g. `non-empty-string` /
289
+ # `positive-int`). `canonical_name` is private on both
290
+ # carriers; the LSP layer is a trusted internal consumer
291
+ # and `send` is the documented escape hatch for surfacing
292
+ # display-level metadata. Returns nil for unrefined carriers
293
+ # and for refinements that don't have a canonical name (those
294
+ # are presented through the predicate-id operator form by
295
+ # `describe`).
296
+ def refinement_name_for(type)
297
+ return nil unless type.is_a?(Type::Refined) || type.is_a?(Type::Difference)
298
+
299
+ type.send(:canonical_name)
300
+ end
301
+
302
+ def render_default(node, type)
303
+ body = +"```ruby\n"
304
+ body << "type: #{type.describe}\n"
305
+ body << "erased: #{type.erase_to_rbs}\n"
306
+ body << "node: #{node.class}\n"
307
+ body << "```"
308
+ body
309
+ end
310
+ end
311
+ end
312
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "language_server-protocol"
5
+
6
+ module Rigor
7
+ module LanguageServer
8
+ # JSON-RPC dispatch loop. Drains messages from `reader`, routes
9
+ # each to `server.dispatch`, and writes responses back through
10
+ # `writer`. Stops when either the reader hits EOF (client closed
11
+ # its end of the pipe) or the server transitions to `:exited`.
12
+ #
13
+ # The Loop knows the request / notification distinction from the
14
+ # presence of the `id` field on the inbound JSON-RPC envelope:
15
+ #
16
+ # - Request (`id` present) → ALWAYS gets a response (success or
17
+ # error). `Server#dispatch` returning nil for a request maps
18
+ # to `result: null` per the LSP shutdown contract.
19
+ # - Notification (`id` absent) → NEVER gets a response. The
20
+ # dispatcher's return value is discarded.
21
+ #
22
+ # JSON parse errors at the framing boundary surface as an LSP
23
+ # `ParseError` (-32700) response with `id: null` per JSON-RPC
24
+ # spec § 5.1; the loop continues so a corrupt frame doesn't
25
+ # poison the rest of the session.
26
+ class Loop
27
+ def initialize(reader:, writer:, server:)
28
+ @reader = reader
29
+ @writer = writer
30
+ @server = server
31
+ end
32
+
33
+ def run
34
+ @reader.read do |request|
35
+ handle(request)
36
+ break if @server.exited?
37
+ end
38
+ rescue JSON::ParserError => e
39
+ @writer.write(id: nil, error: { code: Server::ERROR_PARSE_ERROR, message: e.message })
40
+ end
41
+
42
+ private
43
+
44
+ def handle(request)
45
+ method = request[:method]
46
+ params = request[:params]
47
+ id = request[:id]
48
+
49
+ result = @server.dispatch(method, params)
50
+ return if id.nil? # notification — no response.
51
+
52
+ write_response(id, result)
53
+ end
54
+
55
+ # Maps the dispatcher's return value to a JSON-RPC response
56
+ # envelope. `Server#dispatch` returns one of three shapes:
57
+ #
58
+ # - `{ error: {...} }` — surfaced as `{ id, error: {...} }`.
59
+ # - any other Hash — surfaced as `{ id, result: hash }`.
60
+ # - `nil` — surfaced as `{ id, result: null }` (the LSP
61
+ # `shutdown` contract).
62
+ def write_response(id, result)
63
+ if result.is_a?(Hash) && result.key?(:error)
64
+ @writer.write(id: id, error: result[:error])
65
+ else
66
+ @writer.write(id: id, result: result)
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../environment"
4
+ require_relative "../cache/store"
5
+ require_relative "../analysis/runner"
6
+
7
+ module Rigor
8
+ module LanguageServer
9
+ # Per-session cache of the project-wide analyzer state the LSP
10
+ # reads on every request — chiefly the `Environment` (with its
11
+ # ~100-300ms RBS env build), a read-only `Cache::Store` that
12
+ # lets the runner hit the on-disk RBS cache without writing
13
+ # back, and (since the pre-pass cache slice) a frozen
14
+ # {Rigor::Analysis::ProjectScan} snapshot covering the
15
+ # plugin registry, dependency-source index, and pre-pass
16
+ # scanner outputs.
17
+ #
18
+ # The pre-pass scan lets `DiagnosticPublisher#run_analysis`
19
+ # build a `Runner` with `prebuilt:` so per-buffer publishes
20
+ # skip plugin `#prepare`, the synthetic-method scanner, the
21
+ # project-patched scanner, and the dependency-source walker.
22
+ # For projects with substrate plugins / opt-in dependency
23
+ # source / sizeable `pre_eval:` configuration this cuts
24
+ # publish wall time substantially — for the trivial case
25
+ # the savings are small (the per-publish path is already
26
+ # ≈2ms once Environment is warm).
27
+ #
28
+ # Invalidation:
29
+ # - `#invalidate!` drops the cached environment AND project
30
+ # scan + bumps the generation counter; the next reader
31
+ # rebuilds. Watched-file changes
32
+ # (`workspace/didChangeWatchedFiles`) and configuration
33
+ # refreshes (`workspace/didChangeConfiguration`) both
34
+ # trigger this — the next publish observes the new
35
+ # project state.
36
+ # - The cache store is NOT invalidated on file change — it's
37
+ # content-addressed (digests over file contents), so stale
38
+ # entries naturally lose their key match. We DO keep a single
39
+ # Store instance across the session so the in-process memo
40
+ # serves repeat reads cheaply.
41
+ #
42
+ # Editor-mode trade-off: the cached `project_scan` was built
43
+ # without any `buffer:` binding so scanners observed on-disk
44
+ # bytes for every project file (including the file the user
45
+ # is editing right now). Edits to a file that itself declares
46
+ # `Plugin::Macro::HeredocTemplate` consumers or
47
+ # `pre_eval:`-listed methods are not visible until a
48
+ # watched-file change triggers `invalidate!`. The common
49
+ # editor flow (save → file watch fires → publish) refreshes
50
+ # automatically; the rare in-flight edit to a substrate-DSL
51
+ # file is the documented edge case.
52
+ class ProjectContext
53
+ attr_reader :configuration, :generation
54
+
55
+ def initialize(configuration:)
56
+ @configuration = configuration
57
+ @generation = 0
58
+ @environment = nil
59
+ @cache_store = nil
60
+ @project_scan = nil
61
+ end
62
+
63
+ # Returns the cached `Rigor::Environment` for this session,
64
+ # building it on first access. The build includes the
65
+ # project's full scan state (plugin registry, dependency-source
66
+ # index, synthetic-method / project-patched indexes — drawn
67
+ # from {#project_scan}) AND every Bundler / RBS-collection
68
+ # axis the runner consults at build time, so the resulting
69
+ # env is bit-for-bit equivalent to what `Runner.run` would
70
+ # have built on its own.
71
+ #
72
+ # `DiagnosticPublisher` passes this env through
73
+ # `Runner.new(environment: …)` so per-buffer publishes share
74
+ # one instance instead of repeating the
75
+ # `Environment.for_project` build per call (bundler
76
+ # discovery, RbsLoader construction, signature_paths
77
+ # composition). Subsequent calls return the same instance
78
+ # until `#invalidate!` drops the cache.
79
+ #
80
+ # The runner attaches its own per-call reporter pair onto
81
+ # the shared env's `Reporters` slot at the start of each
82
+ # `#analyze_files` — so diagnostic events stay scoped to a
83
+ # single publish and do NOT accumulate across publishes.
84
+ def environment
85
+ @environment ||= Environment.for_project(
86
+ libraries: @configuration.libraries,
87
+ signature_paths: @configuration.signature_paths,
88
+ cache_store: cache_store,
89
+ plugin_registry: project_scan.plugin_registry,
90
+ dependency_source_index: project_scan.dependency_source_index,
91
+ synthetic_method_index: project_scan.synthetic_method_index,
92
+ project_patched_methods: project_scan.project_patched_methods,
93
+ bundler_bundle_path: @configuration.bundler_bundle_path,
94
+ bundler_auto_detect: @configuration.bundler_auto_detect,
95
+ bundler_lockfile: @configuration.bundler_lockfile,
96
+ rbs_collection_lockfile: @configuration.rbs_collection_lockfile,
97
+ rbs_collection_auto_detect: @configuration.rbs_collection_auto_detect
98
+ )
99
+ end
100
+
101
+ # Returns the per-session read-only `Cache::Store`. Read-only
102
+ # so multiple LSP sessions against the same project don't
103
+ # race on cache writes — same contract editor mode v1 already
104
+ # uses for the CLI `--tmp-file` path.
105
+ def cache_store
106
+ @cache_store ||= Cache::Store.new(root: @configuration.cache_path, read_only: true)
107
+ end
108
+
109
+ # Returns the cached {Rigor::Analysis::ProjectScan} for this
110
+ # session, building it lazily by spinning up a project-only
111
+ # `Runner` (no buffer binding, no `paths` override) and
112
+ # calling `#prepare_project_scan`. The cold build pays the
113
+ # full pre-pass cost once per generation; every subsequent
114
+ # `Runner.new(prebuilt: project_scan)` skips it.
115
+ def project_scan
116
+ @project_scan ||= build_project_scan
117
+ end
118
+
119
+ # Drops every cached collaborator and bumps the generation.
120
+ # The next reader rebuilds from scratch. Triggered by
121
+ # `workspace/didChangeWatchedFiles` for project source files
122
+ # and by `workspace/didChangeConfiguration`.
123
+ def invalidate!
124
+ @generation += 1
125
+ @environment = nil
126
+ @project_scan = nil
127
+ # Cache store stays — it's content-addressed; a stale env
128
+ # build won't be served because the file digest mixed into
129
+ # the cache key has changed.
130
+ nil
131
+ end
132
+
133
+ private
134
+
135
+ def build_project_scan
136
+ runner = Analysis::Runner.new(
137
+ configuration: @configuration,
138
+ cache_store: cache_store,
139
+ collect_stats: false
140
+ )
141
+ runner.prepare_project_scan
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "uri"
6
+
7
+ module Rigor
8
+ module LanguageServer
9
+ # Answers `textDocument/selectionRange` requests. For each
10
+ # position, returns a linked list of SelectionRange entries —
11
+ # innermost first, each pointing at its `parent` (the next-
12
+ # wider expression). Editors use this for "expand selection":
13
+ # one keystroke moves up the chain, another moves further out,
14
+ # all the way to the root.
15
+ class SelectionRangeProvider
16
+ def initialize(buffer_table:, project_context:)
17
+ @buffer_table = buffer_table
18
+ @project_context = project_context
19
+ end
20
+
21
+ # @param positions [Array<Hash>] LSP `Position[]` — each
22
+ # `{ line:, character: }` 0-based.
23
+ # @return [Array<Hash>, nil] one `SelectionRange` per
24
+ # position, or nil when the URI / buffer isn't resolvable.
25
+ def provide(uri, positions)
26
+ path = Uri.to_path(uri)
27
+ return nil if path.nil?
28
+
29
+ entry = @buffer_table[uri]
30
+ return nil if entry.nil?
31
+
32
+ parse_result = Prism.parse(entry.bytes, filepath: path,
33
+ version: @project_context.configuration.target_ruby)
34
+ root = parse_result.value
35
+
36
+ positions.map do |pos|
37
+ offset = byte_offset_for(entry.bytes, pos.fetch(:line), pos.fetch(:character))
38
+ next nil if offset.nil?
39
+
40
+ build_chain(root, offset)
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ # Walks the AST top-down; each node whose location encloses
47
+ # `offset` gets appended to the chain. Returns root→innermost.
48
+ def ancestor_chain(node, offset, chain = [])
49
+ return chain unless node.is_a?(Prism::Node)
50
+ return chain unless node.location && offset_in?(node.location, offset)
51
+
52
+ chain << node
53
+ node.compact_child_nodes.each { |child| ancestor_chain(child, offset, chain) }
54
+ chain
55
+ end
56
+
57
+ def offset_in?(location, offset)
58
+ offset.between?(location.start_offset, location.end_offset)
59
+ end
60
+
61
+ # Folds the root→innermost chain into the LSP `SelectionRange`
62
+ # linked-list shape — innermost on the outside (the request's
63
+ # return value) with `parent` chained outward. Editor "expand
64
+ # selection" follows `.parent` one step per invocation.
65
+ def build_chain(root, offset)
66
+ chain = ancestor_chain(root, offset)
67
+ return nil if chain.empty?
68
+
69
+ chain.reduce(nil) do |parent, node|
70
+ { range: lsp_range(node), parent: parent }
71
+ end
72
+ end
73
+
74
+ def lsp_range(node)
75
+ loc = node.location
76
+ {
77
+ start: { line: loc.start_line - 1, character: loc.start_column },
78
+ end: { line: loc.end_line - 1, character: loc.end_column }
79
+ }
80
+ end
81
+
82
+ def byte_offset_for(bytes, line, character)
83
+ offset = 0
84
+ bytes.each_line.with_index do |line_bytes, idx|
85
+ return offset + character if idx == line
86
+
87
+ offset += line_bytes.bytesize
88
+ end
89
+ nil
90
+ end
91
+ end
92
+ end
93
+ end