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.
- checksums.yaml +4 -4
- data/README.md +69 -56
- 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/fact_store.rb +15 -3
- data/lib/rigor/analysis/project_scan.rb +39 -0
- data/lib/rigor/analysis/result.rb +11 -3
- data/lib/rigor/analysis/run_stats.rb +193 -0
- data/lib/rigor/analysis/runner.rb +681 -19
- data/lib/rigor/analysis/worker_session.rb +339 -0
- data/lib/rigor/builtins/hkt_builtins.rb +342 -0
- data/lib/rigor/builtins/imported_refinements.rb +6 -2
- data/lib/rigor/builtins/regex_refinement.rb +17 -12
- data/lib/rigor/builtins/static_return_refinements.rb +120 -0
- data/lib/rigor/cache/rbs_descriptor.rb +3 -1
- data/lib/rigor/cache/store.rb +72 -9
- data/lib/rigor/cli/lsp_command.rb +129 -0
- data/lib/rigor/cli/type_of_command.rb +44 -5
- data/lib/rigor/cli.rb +122 -10
- data/lib/rigor/configuration.rb +168 -7
- data/lib/rigor/environment/bundle_sig_discovery.rb +198 -0
- data/lib/rigor/environment/class_registry.rb +12 -3
- data/lib/rigor/environment/hkt_registry_holder.rb +33 -0
- data/lib/rigor/environment/lockfile_resolver.rb +125 -0
- data/lib/rigor/environment/rbs_collection_discovery.rb +126 -0
- data/lib/rigor/environment/rbs_coverage_report.rb +112 -0
- data/lib/rigor/environment/rbs_loader.rb +238 -7
- data/lib/rigor/environment/reflection.rb +152 -0
- data/lib/rigor/environment/reporters.rb +40 -0
- data/lib/rigor/environment.rb +179 -10
- data/lib/rigor/inference/acceptance.rb +83 -4
- data/lib/rigor/inference/builtins/method_catalog.rb +12 -5
- data/lib/rigor/inference/builtins/numeric_catalog.rb +15 -4
- data/lib/rigor/inference/expression_typer.rb +59 -2
- 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/macro_block_self_type.rb +96 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +29 -29
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -4
- data/lib/rigor/inference/method_dispatcher/method_folding.rb +18 -1
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +126 -31
- data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +87 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +46 -40
- data/lib/rigor/inference/method_dispatcher.rb +282 -6
- data/lib/rigor/inference/method_parameter_binder.rb +21 -11
- data/lib/rigor/inference/narrowing.rb +127 -8
- 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.rb +86 -0
- data/lib/rigor/inference/synthetic_method_index.rb +82 -0
- data/lib/rigor/inference/synthetic_method_scanner.rb +599 -0
- 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/blueprint.rb +60 -0
- data/lib/rigor/plugin/loader.rb +3 -1
- data/lib/rigor/plugin/macro/block_as_method.rb +131 -0
- data/lib/rigor/plugin/macro/external_file.rb +143 -0
- data/lib/rigor/plugin/macro/heredoc_template.rb +315 -0
- data/lib/rigor/plugin/macro/trait_registry.rb +198 -0
- data/lib/rigor/plugin/macro.rb +31 -0
- data/lib/rigor/plugin/manifest.rb +127 -9
- data/lib/rigor/plugin/registry.rb +51 -2
- data/lib/rigor/plugin.rb +1 -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/trinary.rb +15 -11
- data/lib/rigor/type/app.rb +107 -0
- data/lib/rigor/type/bot.rb +6 -3
- data/lib/rigor/type/combinator.rb +12 -1
- data/lib/rigor/type/integer_range.rb +7 -7
- data/lib/rigor/type/refined.rb +18 -12
- data/lib/rigor/type/top.rb +4 -3
- data/lib/rigor/type.rb +1 -0
- data/lib/rigor/type_node/generic.rb +7 -1
- data/lib/rigor/type_node/identifier.rb +9 -1
- data/lib/rigor/type_node/string_literal.rb +4 -1
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/environment.rbs +11 -4
- data/sig/rigor/inference.rbs +2 -0
- data/sig/rigor/plugin/blueprint.rbs +7 -0
- data/sig/rigor/plugin/manifest.rbs +1 -1
- data/sig/rigor/plugin/registry.rbs +14 -1
- data/sig/rigor.rbs +37 -2
- 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
|