rigortype 0.1.5 → 0.1.7
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 +76 -79
- data/lib/rigor/analysis/baseline.rb +347 -0
- data/lib/rigor/analysis/buffer_binding.rb +36 -0
- data/lib/rigor/analysis/check_rules.rb +68 -3
- data/lib/rigor/analysis/dependency_source_inference/index.rb +14 -1
- data/lib/rigor/analysis/dependency_source_inference/return_type_heuristic.rb +105 -0
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +32 -12
- data/lib/rigor/analysis/project_scan.rb +39 -0
- data/lib/rigor/analysis/runner.rb +309 -22
- data/lib/rigor/analysis/worker_session.rb +14 -2
- data/lib/rigor/builtins/hkt_builtins.rb +342 -0
- data/lib/rigor/builtins/static_return_refinements.rb +142 -0
- data/lib/rigor/cache/store.rb +33 -3
- data/lib/rigor/cli/baseline_command.rb +377 -0
- data/lib/rigor/cli/lsp_command.rb +129 -0
- data/lib/rigor/cli/type_of_command.rb +44 -5
- data/lib/rigor/cli.rb +142 -13
- data/lib/rigor/configuration.rb +58 -2
- data/lib/rigor/environment/hkt_registry_holder.rb +33 -0
- data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
- data/lib/rigor/environment/rbs_loader.rb +67 -2
- data/lib/rigor/environment/reporters.rb +40 -0
- data/lib/rigor/environment.rb +119 -9
- data/lib/rigor/flow_contribution/fact.rb +20 -10
- data/lib/rigor/inference/acceptance.rb +48 -3
- data/lib/rigor/inference/expression_typer.rb +64 -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/method_dispatcher/overload_selector.rb +125 -30
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +32 -11
- data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +87 -0
- data/lib/rigor/inference/method_dispatcher.rb +174 -6
- data/lib/rigor/inference/narrowing.rb +103 -1
- 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 +209 -19
- data/lib/rigor/inference/statement_evaluator.rb +172 -11
- data/lib/rigor/inference/synthetic_method_scanner.rb +94 -16
- data/lib/rigor/language_server/buffer_table.rb +63 -0
- data/lib/rigor/language_server/completion_provider.rb +438 -0
- data/lib/rigor/language_server/debouncer.rb +86 -0
- data/lib/rigor/language_server/diagnostic_publisher.rb +167 -0
- data/lib/rigor/language_server/document_symbol_provider.rb +142 -0
- data/lib/rigor/language_server/folding_range_provider.rb +75 -0
- data/lib/rigor/language_server/hover_provider.rb +74 -0
- data/lib/rigor/language_server/hover_renderer.rb +312 -0
- data/lib/rigor/language_server/loop.rb +71 -0
- data/lib/rigor/language_server/project_context.rb +145 -0
- data/lib/rigor/language_server/selection_range_provider.rb +93 -0
- data/lib/rigor/language_server/server.rb +384 -0
- data/lib/rigor/language_server/signature_help_provider.rb +249 -0
- data/lib/rigor/language_server/synchronized_writer.rb +28 -0
- data/lib/rigor/language_server/uri.rb +40 -0
- data/lib/rigor/language_server.rb +29 -0
- data/lib/rigor/plugin/base.rb +63 -0
- data/lib/rigor/plugin/macro/heredoc_template.rb +127 -13
- data/lib/rigor/plugin/macro/trait_registry.rb +1 -1
- data/lib/rigor/plugin/manifest.rb +54 -7
- data/lib/rigor/plugin/registry.rb +19 -0
- data/lib/rigor/rbs_extended/hkt_directives.rb +326 -0
- data/lib/rigor/rbs_extended.rb +82 -2
- data/lib/rigor/sig_gen/generator.rb +12 -3
- data/lib/rigor/type/app.rb +107 -0
- data/lib/rigor/type.rb +1 -0
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/environment.rbs +10 -4
- data/sig/rigor/inference.rbs +2 -0
- data/sig/rigor.rbs +4 -1
- metadata +56 -1
|
@@ -0,0 +1,249 @@
|
|
|
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/refined"
|
|
15
|
+
require_relative "../type/difference"
|
|
16
|
+
require_relative "../type/tuple"
|
|
17
|
+
require_relative "../type/hash_shape"
|
|
18
|
+
|
|
19
|
+
module Rigor
|
|
20
|
+
module LanguageServer
|
|
21
|
+
# Answers `textDocument/signatureHelp` requests. When the user
|
|
22
|
+
# types `(` inside a method call (`obj.foo(|`) editors fire
|
|
23
|
+
# `signatureHelp` to show the method's parameter signature
|
|
24
|
+
# inline. The provider parses the buffer, locates the enclosing
|
|
25
|
+
# `CallNode`, infers the receiver's type, and returns the
|
|
26
|
+
# method's first overload as a `SignatureInformation`.
|
|
27
|
+
#
|
|
28
|
+
# Slice C1 (this commit) ships:
|
|
29
|
+
# - Sentinel patching for `obj.foo(|` so Prism's parse
|
|
30
|
+
# succeeds (mirrors `CompletionProvider`'s slice B4 pattern).
|
|
31
|
+
# - First-overload signature only.
|
|
32
|
+
# - Active parameter = comma count before cursor.
|
|
33
|
+
#
|
|
34
|
+
# Multi-overload presentation + `documentation` field +
|
|
35
|
+
# active-parameter override per overload land in follow-up
|
|
36
|
+
# slices (queued in the design doc § "Out of scope for v2").
|
|
37
|
+
class SignatureHelpProvider
|
|
38
|
+
ARG_SENTINEL = "__rigor_lsp_arg_sentinel__"
|
|
39
|
+
private_constant :ARG_SENTINEL
|
|
40
|
+
|
|
41
|
+
def initialize(buffer_table:, project_context:)
|
|
42
|
+
@buffer_table = buffer_table
|
|
43
|
+
@project_context = project_context
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [Hash, nil] LSP `SignatureHelp` payload or nil
|
|
47
|
+
# when the cursor isn't inside a resolvable method call.
|
|
48
|
+
def provide(uri:, line:, character:, context: nil)
|
|
49
|
+
_ = context # Trigger info accepted but not routed in v1.
|
|
50
|
+
path = Uri.to_path(uri)
|
|
51
|
+
return nil if path.nil?
|
|
52
|
+
|
|
53
|
+
entry = @buffer_table[uri]
|
|
54
|
+
return nil if entry.nil?
|
|
55
|
+
|
|
56
|
+
bytes, locate_at = parse_attempt_bytes(entry.bytes, line, character)
|
|
57
|
+
parse_result = Prism.parse(bytes, filepath: path,
|
|
58
|
+
version: @project_context.configuration.target_ruby)
|
|
59
|
+
return nil unless parse_result.errors.empty?
|
|
60
|
+
|
|
61
|
+
cursor_offset = byte_offset_for(bytes, locate_at[0], locate_at[1])
|
|
62
|
+
return nil if cursor_offset.nil?
|
|
63
|
+
|
|
64
|
+
call_node = enclosing_call_for_offset(parse_result.value, cursor_offset)
|
|
65
|
+
return nil if call_node.nil?
|
|
66
|
+
|
|
67
|
+
build_signature(call_node, parse_result.value, path, bytes, locate_at[0], locate_at[1])
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def parse_attempt_bytes(original_bytes, line, character)
|
|
73
|
+
return [original_bytes, [line, character]] if Prism.parse(original_bytes).errors.empty?
|
|
74
|
+
|
|
75
|
+
patch_with_arg_sentinel(original_bytes, line, character)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Mid-edit buffer at `obj.foo(|` or `obj.foo(1,|`: truncate
|
|
79
|
+
# everything from the cursor onwards and append `SENTINEL)`
|
|
80
|
+
# so the call is syntactically complete. The truncation is
|
|
81
|
+
# aggressive — signatureHelp only cares about the enclosing
|
|
82
|
+
# call's signature; downstream content (closing parens,
|
|
83
|
+
# subsequent statements) is irrelevant.
|
|
84
|
+
def patch_with_arg_sentinel(original_bytes, line, character)
|
|
85
|
+
prefix_offset = byte_offset_for(original_bytes, line, character)
|
|
86
|
+
return [original_bytes, [line, character]] if prefix_offset.nil?
|
|
87
|
+
|
|
88
|
+
prefix = original_bytes.byteslice(0, prefix_offset)
|
|
89
|
+
stripped = prefix.rstrip
|
|
90
|
+
return [original_bytes, [line, character]] unless stripped.end_with?("(") || stripped.end_with?(",")
|
|
91
|
+
|
|
92
|
+
patched = "#{prefix}#{ARG_SENTINEL})\n"
|
|
93
|
+
[patched, [line, character]]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Walks the AST for the smallest CallNode whose `arguments`
|
|
97
|
+
# location encloses the cursor offset. Prism doesn't expose
|
|
98
|
+
# parent pointers, so NodeLocator's leaf-returning shape
|
|
99
|
+
# isn't enough; we re-walk. For LSP usage this is cheap —
|
|
100
|
+
# the buffer is parsed once per request.
|
|
101
|
+
def enclosing_call_for_offset(root, cursor_offset)
|
|
102
|
+
result = nil
|
|
103
|
+
walk = lambda do |n|
|
|
104
|
+
next unless n.is_a?(Prism::Node)
|
|
105
|
+
|
|
106
|
+
if n.is_a?(Prism::CallNode) && n.arguments && offset_in?(n.arguments.location, cursor_offset)
|
|
107
|
+
result = n # Innermost-wins because we keep walking children.
|
|
108
|
+
end
|
|
109
|
+
n.compact_child_nodes.each(&walk)
|
|
110
|
+
end
|
|
111
|
+
walk.call(root)
|
|
112
|
+
result
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def offset_in?(location, offset)
|
|
116
|
+
offset.between?(location.start_offset, location.end_offset)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def build_signature(call_node, root, path, bytes, line, character)
|
|
120
|
+
scope_index = build_scope_index(root, path)
|
|
121
|
+
receiver_node = call_node.receiver
|
|
122
|
+
return nil if receiver_node.nil?
|
|
123
|
+
|
|
124
|
+
receiver_type = scope_index[receiver_node].type_of(receiver_node)
|
|
125
|
+
definition = lookup_method(receiver_type, call_node.name, scope_index[receiver_node])
|
|
126
|
+
return nil if definition.nil? || definition.method_types.empty?
|
|
127
|
+
|
|
128
|
+
active_param = active_parameter_index(call_node, bytes, line, character)
|
|
129
|
+
doc = rbs_documentation(definition)
|
|
130
|
+
signatures = definition.method_types.map do |method_type|
|
|
131
|
+
info = {
|
|
132
|
+
label: "#{call_node.name}#{method_type}",
|
|
133
|
+
parameters: parameter_information(method_type)
|
|
134
|
+
}
|
|
135
|
+
info[:documentation] = { kind: "markdown", value: doc } if doc
|
|
136
|
+
info
|
|
137
|
+
end
|
|
138
|
+
{
|
|
139
|
+
signatures: signatures,
|
|
140
|
+
# `activeSignature` is the index editors highlight by
|
|
141
|
+
# default. Slice C2 picks the first overload uniformly;
|
|
142
|
+
# a future slice could choose the overload that best
|
|
143
|
+
# matches the current argument shape.
|
|
144
|
+
activeSignature: 0,
|
|
145
|
+
activeParameter: active_param
|
|
146
|
+
}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Builds the LSP `ParameterInformation[]` for a method type's
|
|
150
|
+
# parameter list. Each entry's `label` is a STRING form
|
|
151
|
+
# (e.g. `"::int width"`, `"?::string pad_string"`); LSP's
|
|
152
|
+
# offset-tuple form (`[start, end]`) for in-signature
|
|
153
|
+
# highlighting is queued. Order matches `activeParameter`'s
|
|
154
|
+
# index expectation: required positionals, then optionals,
|
|
155
|
+
# then rest, then trailing, then required keywords, then
|
|
156
|
+
# optional keywords, then rest keywords.
|
|
157
|
+
def parameter_information(method_type) # rubocop:disable Metrics/AbcSize
|
|
158
|
+
func = method_type.type
|
|
159
|
+
return [] unless func.respond_to?(:required_positionals)
|
|
160
|
+
|
|
161
|
+
params = func.required_positionals.map { |p| { label: format_param(p) } }
|
|
162
|
+
func.optional_positionals.each { |p| params << { label: "?#{format_param(p)}" } }
|
|
163
|
+
params << { label: "*#{format_param(func.rest_positionals)}" } if func.rest_positionals
|
|
164
|
+
func.trailing_positionals.each { |p| params << { label: format_param(p) } }
|
|
165
|
+
func.required_keywords.each { |name, p| params << { label: "#{name}: #{format_param(p)}" } }
|
|
166
|
+
func.optional_keywords.each { |name, p| params << { label: "?#{name}: #{format_param(p)}" } }
|
|
167
|
+
params << { label: "**#{format_param(func.rest_keywords)}" } if func.rest_keywords
|
|
168
|
+
params
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def format_param(param)
|
|
172
|
+
param.name ? "#{param.type} #{param.name}" : param.type.to_s
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Identical contract to HoverRenderer#rbs_documentation —
|
|
176
|
+
# surfaces the method's RBS comment text or nil. Kept inline
|
|
177
|
+
# rather than extracted to a shared mixin because the two
|
|
178
|
+
# call sites are small and the shape may diverge (signatureHelp
|
|
179
|
+
# might want per-parameter docs split out; hover wants the
|
|
180
|
+
# full paragraph).
|
|
181
|
+
def rbs_documentation(definition)
|
|
182
|
+
comments = definition.respond_to?(:comments) ? definition.comments : nil
|
|
183
|
+
return nil if comments.nil? || comments.empty?
|
|
184
|
+
|
|
185
|
+
text = comments.map(&:string).join("\n\n").strip
|
|
186
|
+
text.empty? ? nil : text
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def lookup_method(receiver_type, method_name, scope)
|
|
190
|
+
case receiver_type
|
|
191
|
+
when Type::Singleton
|
|
192
|
+
Reflection.singleton_method_definition(receiver_type.class_name, method_name, scope: scope)
|
|
193
|
+
when Type::Refined, Type::Difference
|
|
194
|
+
lookup_method(receiver_type.base, method_name, scope)
|
|
195
|
+
else
|
|
196
|
+
class_name = nominal_class_name(receiver_type)
|
|
197
|
+
return nil if class_name.nil?
|
|
198
|
+
|
|
199
|
+
Reflection.instance_method_definition(class_name, method_name, scope: scope)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Mirrors CompletionProvider's receiver-type mapping. Tuple →
|
|
204
|
+
# Array, HashShape → Hash, Refined / Difference unwrap to
|
|
205
|
+
# their base (handled in `lookup_method` above for clarity).
|
|
206
|
+
def nominal_class_name(type)
|
|
207
|
+
case type
|
|
208
|
+
when Type::Nominal then type.class_name
|
|
209
|
+
when Type::Constant then type.value.class.name
|
|
210
|
+
when Type::Tuple then "Array"
|
|
211
|
+
when Type::HashShape then "Hash"
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def build_scope_index(root, _path)
|
|
216
|
+
scope = Scope.empty(environment: @project_context.environment)
|
|
217
|
+
Inference::ScopeIndexer.index(root, default_scope: scope)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Counts commas in the buffer between the call's opening `(`
|
|
221
|
+
# and the cursor position. Cursor on the first argument → 0;
|
|
222
|
+
# after one comma → 1; etc. Bounded by the call's arguments
|
|
223
|
+
# location so commas in nested expressions don't bleed in.
|
|
224
|
+
def active_parameter_index(call_node, bytes, line, character)
|
|
225
|
+
return 0 if call_node.arguments.nil?
|
|
226
|
+
|
|
227
|
+
cursor_offset = byte_offset_for(bytes, line, character)
|
|
228
|
+
args_loc = call_node.arguments.location
|
|
229
|
+
return 0 if args_loc.nil? || cursor_offset.nil?
|
|
230
|
+
|
|
231
|
+
scan_start = args_loc.start_offset
|
|
232
|
+
scan_end = [cursor_offset, args_loc.end_offset].min
|
|
233
|
+
return 0 if scan_end <= scan_start
|
|
234
|
+
|
|
235
|
+
bytes.byteslice(scan_start, scan_end - scan_start).count(",")
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def byte_offset_for(bytes, line, character)
|
|
239
|
+
offset = 0
|
|
240
|
+
bytes.each_line.with_index do |line_bytes, idx|
|
|
241
|
+
return offset + character if idx == line
|
|
242
|
+
|
|
243
|
+
offset += line_bytes.bytesize
|
|
244
|
+
end
|
|
245
|
+
nil
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module LanguageServer
|
|
5
|
+
# Wraps the LSP gem's `Io::Writer` with a Mutex so concurrent
|
|
6
|
+
# writes (the dispatch loop's response writes + the Debouncer's
|
|
7
|
+
# async `publishDiagnostics` writes) don't interleave on the
|
|
8
|
+
# shared STDOUT.
|
|
9
|
+
#
|
|
10
|
+
# Pass-through proxy: `#write(message)` is the only call site
|
|
11
|
+
# the rest of the LSP uses; `#close` is forwarded for
|
|
12
|
+
# completeness.
|
|
13
|
+
class SynchronizedWriter
|
|
14
|
+
def initialize(inner)
|
|
15
|
+
@inner = inner
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def write(message)
|
|
20
|
+
@mutex.synchronize { @inner.write(message) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def close
|
|
24
|
+
@mutex.synchronize { @inner.close }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module LanguageServer
|
|
5
|
+
# LSP DocumentUri ↔ filesystem path conversions. v1 supports
|
|
6
|
+
# only `file://` URIs; other schemes (e.g. `untitled:`) return
|
|
7
|
+
# nil from `#to_path` so the caller can short-circuit.
|
|
8
|
+
#
|
|
9
|
+
# Windows drive-letter handling: `file:///C:/path` → `C:/path`.
|
|
10
|
+
# The leading slash after the scheme is dropped on Windows; on
|
|
11
|
+
# POSIX it stays. v1 ships POSIX behaviour; Windows specifics
|
|
12
|
+
# land when Windows CI is wired (see design doc § "Open
|
|
13
|
+
# questions").
|
|
14
|
+
module Uri
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
FILE_SCHEME = "file://"
|
|
18
|
+
private_constant :FILE_SCHEME
|
|
19
|
+
|
|
20
|
+
# @return [String, nil] absolute filesystem path for a
|
|
21
|
+
# `file://` URI, or nil for unsupported schemes.
|
|
22
|
+
def to_path(uri)
|
|
23
|
+
return nil unless uri.is_a?(String) && uri.start_with?(FILE_SCHEME)
|
|
24
|
+
|
|
25
|
+
# Percent-decode at the BYTE level so multi-byte UTF-8
|
|
26
|
+
# escapes (`%E6%97%A5` → `日`) reassemble correctly. Each
|
|
27
|
+
# `%xx` decodes to one raw byte; the result is a byte string
|
|
28
|
+
# we re-interpret as UTF-8. `delete_prefix` always returns
|
|
29
|
+
# a String (vs `byteslice` whose RBS return is `String?`).
|
|
30
|
+
uri.delete_prefix(FILE_SCHEME).b
|
|
31
|
+
.gsub(/%([0-9A-Fa-f]{2})/) { ::Regexp.last_match(1).hex.chr }
|
|
32
|
+
.force_encoding(Encoding::UTF_8)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def from_path(path)
|
|
36
|
+
"#{FILE_SCHEME}#{path}"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
# The Language Server subsystem. See
|
|
5
|
+
# `docs/design/20260517-language-server.md` for the design.
|
|
6
|
+
# Slice 1 ships the namespace + a minimal {Server} lifecycle the
|
|
7
|
+
# `rigor lsp` CLI subcommand can drive. Later slices add the
|
|
8
|
+
# stdio JSON-RPC transport (slice 2), the BufferTable (slice 3),
|
|
9
|
+
# `publishDiagnostics` (slice 4), and the rest of the v1 capability
|
|
10
|
+
# surface.
|
|
11
|
+
module LanguageServer
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
require_relative "language_server/buffer_table"
|
|
16
|
+
require_relative "language_server/uri"
|
|
17
|
+
require_relative "language_server/project_context"
|
|
18
|
+
require_relative "language_server/debouncer"
|
|
19
|
+
require_relative "language_server/synchronized_writer"
|
|
20
|
+
require_relative "language_server/diagnostic_publisher"
|
|
21
|
+
require_relative "language_server/hover_renderer"
|
|
22
|
+
require_relative "language_server/hover_provider"
|
|
23
|
+
require_relative "language_server/completion_provider"
|
|
24
|
+
require_relative "language_server/signature_help_provider"
|
|
25
|
+
require_relative "language_server/document_symbol_provider"
|
|
26
|
+
require_relative "language_server/folding_range_provider"
|
|
27
|
+
require_relative "language_server/selection_range_provider"
|
|
28
|
+
require_relative "language_server/server"
|
|
29
|
+
require_relative "language_server/loop"
|
data/lib/rigor/plugin/base.rb
CHANGED
|
@@ -243,8 +243,71 @@ module Rigor
|
|
|
243
243
|
end
|
|
244
244
|
end
|
|
245
245
|
|
|
246
|
+
# Builds a `Cache::Descriptor` covering every file matched by
|
|
247
|
+
# `pattern` (a glob, e.g. `"**/*.rb"`) under any of `roots`.
|
|
248
|
+
# Each matching file contributes a `:digest`-comparator
|
|
249
|
+
# `FileEntry` so the cache invalidates on any content change,
|
|
250
|
+
# any addition (a newly-glob-matched file appears in the
|
|
251
|
+
# descriptor), or any removal (the previously-matched file
|
|
252
|
+
# drops out).
|
|
253
|
+
#
|
|
254
|
+
# Pass the returned descriptor as `cache_for(..., descriptor: …)`
|
|
255
|
+
# so the cache key reflects the project files the producer
|
|
256
|
+
# reads from. Without it, `Plugin::Base#cache_for`'s
|
|
257
|
+
# auto-built descriptor only includes files the
|
|
258
|
+
# {Plugin::IoBoundary} has already read in the current
|
|
259
|
+
# process — empty on the first call of a fresh process — so
|
|
260
|
+
# the cache key is identical regardless of project state and
|
|
261
|
+
# warm runs return stale producer output when files have
|
|
262
|
+
# changed between sessions.
|
|
263
|
+
#
|
|
264
|
+
# Discovery-style producers (`actioncable`'s `:channel_index`,
|
|
265
|
+
# `actionmailer`'s `:mailer_index`, `rails-i18n`'s
|
|
266
|
+
# `:locale_index`) all follow the same pattern: walk a glob
|
|
267
|
+
# under one or more search roots, parse / read every match,
|
|
268
|
+
# build a typed index. They MUST call this helper at the
|
|
269
|
+
# `cache_for(descriptor: …)` site to be cache-correct under
|
|
270
|
+
# the persistent `Cache::Store` `rigor check` uses by
|
|
271
|
+
# default.
|
|
272
|
+
#
|
|
273
|
+
# The helper pays one SHA-256 read per matched file at
|
|
274
|
+
# call time; the producer block typically re-reads through
|
|
275
|
+
# `io_boundary.read_file` so the cost is doubled. For
|
|
276
|
+
# discovery globs in the 10-100 file range this is
|
|
277
|
+
# negligible (~ms) relative to the parse + walk the
|
|
278
|
+
# producer does on cache miss.
|
|
279
|
+
#
|
|
280
|
+
# @param roots [Array<String>] search roots (relative to
|
|
281
|
+
# the project root, or absolute paths)
|
|
282
|
+
# @param patterns [Array<String>] glob suffixes joined under
|
|
283
|
+
# each root via `File.join(root, pattern)`. Multiple
|
|
284
|
+
# patterns union into one descriptor (`"**/*.erb",
|
|
285
|
+
# "**/*.html"` etc.).
|
|
286
|
+
# @return [Rigor::Cache::Descriptor]
|
|
287
|
+
def glob_descriptor(roots, *patterns)
|
|
288
|
+
files = collect_glob_files(Array(roots), patterns)
|
|
289
|
+
entries = files.map do |path|
|
|
290
|
+
Cache::Descriptor::FileEntry.new(
|
|
291
|
+
path: path,
|
|
292
|
+
comparator: :digest,
|
|
293
|
+
value: Digest::SHA256.file(path).hexdigest
|
|
294
|
+
)
|
|
295
|
+
end
|
|
296
|
+
Cache::Descriptor.new(files: entries)
|
|
297
|
+
end
|
|
298
|
+
|
|
246
299
|
private
|
|
247
300
|
|
|
301
|
+
def collect_glob_files(roots, patterns)
|
|
302
|
+
matched = roots.flat_map do |root|
|
|
303
|
+
absolute = File.expand_path(root.to_s)
|
|
304
|
+
next [] unless File.directory?(absolute)
|
|
305
|
+
|
|
306
|
+
patterns.flat_map { |pattern| Dir.glob(File.join(absolute, pattern.to_s)) }
|
|
307
|
+
end
|
|
308
|
+
matched.uniq.sort.select { |path| File.file?(path) }
|
|
309
|
+
end
|
|
310
|
+
|
|
248
311
|
# ADR-7 § "Slice 6-B" — composes the per-call cache
|
|
249
312
|
# descriptor from (1) the plugin's PluginEntry template
|
|
250
313
|
# and (2) the IoBoundary's accumulated FileEntry rows.
|
|
@@ -73,8 +73,8 @@ module Rigor
|
|
|
73
73
|
# This file ships the value class only. Slice 2b wires the
|
|
74
74
|
# pre-pass that scans Tier C call sites + the
|
|
75
75
|
# `SyntheticMethodIndex` the dispatcher consults; slice 2c
|
|
76
|
-
# authors `
|
|
77
|
-
# `
|
|
76
|
+
# authors `plugins/rigor-dry-struct/` and
|
|
77
|
+
# `plugins/rigor-dry-types/` as the worked consumers.
|
|
78
78
|
class HeredocTemplate
|
|
79
79
|
NAME_PLACEHOLDER = "\#{name}"
|
|
80
80
|
|
|
@@ -115,37 +115,147 @@ module Rigor
|
|
|
115
115
|
# One row of an emit table: the synthetic method's
|
|
116
116
|
# name-template (the analyzer interpolates `\#{name}` with
|
|
117
117
|
# the call-site literal symbol) and its declared return
|
|
118
|
-
# type
|
|
119
|
-
#
|
|
118
|
+
# type. The return type can be a static String (resolved
|
|
119
|
+
# via `Environment#nominal_for_name` per ADR-16 slice 6b)
|
|
120
|
+
# or a per-call-site lookup ({ReturnsFromArg}) — see
|
|
121
|
+
# [ADR-18](../../../../../docs/adr/18-substrate-per-call-site-return-type.md).
|
|
122
|
+
# When both are nil, the synthesised method's return type
|
|
123
|
+
# falls back to `Dynamic[Top]`.
|
|
120
124
|
class Emit
|
|
121
|
-
attr_reader :name, :returns
|
|
125
|
+
attr_reader :name, :returns, :returns_from_arg
|
|
122
126
|
|
|
123
|
-
def initialize(name:, returns:)
|
|
127
|
+
def initialize(name:, returns: nil, returns_from_arg: nil)
|
|
124
128
|
unless name.is_a?(String) && !name.empty?
|
|
125
129
|
raise ArgumentError,
|
|
126
130
|
"Macro::HeredocTemplate::Emit#name must be a non-empty String, got #{name.inspect}"
|
|
127
131
|
end
|
|
128
|
-
unless returns.is_a?(String) && !returns.empty?
|
|
132
|
+
unless returns.nil? || (returns.is_a?(String) && !returns.empty?)
|
|
129
133
|
raise ArgumentError,
|
|
130
|
-
"Macro::HeredocTemplate::Emit#returns must be a non-empty String, got #{returns.inspect}"
|
|
134
|
+
"Macro::HeredocTemplate::Emit#returns must be a non-empty String or nil, got #{returns.inspect}"
|
|
131
135
|
end
|
|
132
136
|
|
|
133
137
|
@name = name.dup.freeze
|
|
134
|
-
@returns = returns.dup.freeze
|
|
138
|
+
@returns = returns.nil? ? nil : returns.dup.freeze
|
|
139
|
+
@returns_from_arg = ReturnsFromArg.coerce(returns_from_arg)
|
|
135
140
|
freeze
|
|
136
141
|
end
|
|
137
142
|
|
|
138
143
|
def to_h
|
|
139
|
-
{
|
|
144
|
+
{
|
|
145
|
+
"name" => name,
|
|
146
|
+
"returns" => returns,
|
|
147
|
+
"returns_from_arg" => returns_from_arg&.to_h
|
|
148
|
+
}.compact
|
|
140
149
|
end
|
|
141
150
|
|
|
142
151
|
def ==(other)
|
|
143
|
-
other.is_a?(Emit) &&
|
|
152
|
+
other.is_a?(Emit) && to_h == other.to_h
|
|
144
153
|
end
|
|
145
154
|
alias eql? ==
|
|
146
155
|
|
|
147
156
|
def hash
|
|
148
|
-
|
|
157
|
+
to_h.hash
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# ADR-18 — per-call-site return-type DSL. Declares which
|
|
162
|
+
# call-site argument's source representation to look up
|
|
163
|
+
# in a cross-plugin fact channel for the synthesised
|
|
164
|
+
# method's return type.
|
|
165
|
+
#
|
|
166
|
+
# Authoring shape:
|
|
167
|
+
#
|
|
168
|
+
# returns_from_arg: {
|
|
169
|
+
# position: 1,
|
|
170
|
+
# lookup_via: { plugin_id: "dry-types", fact: :dry_type_aliases }
|
|
171
|
+
# }
|
|
172
|
+
#
|
|
173
|
+
# Slice 1 (this file) ships the value class + validation
|
|
174
|
+
# only. The scanner-side arg-position extraction +
|
|
175
|
+
# fact-store lookup land in slice 2 / 3.
|
|
176
|
+
class ReturnsFromArg
|
|
177
|
+
attr_reader :position, :plugin_id, :fact
|
|
178
|
+
|
|
179
|
+
# @return [ReturnsFromArg, nil] coerced value class
|
|
180
|
+
# for a Hash / nil / ReturnsFromArg input. Raises on
|
|
181
|
+
# any other shape so manifest authoring failures
|
|
182
|
+
# surface at construction time.
|
|
183
|
+
def self.coerce(value)
|
|
184
|
+
return nil if value.nil?
|
|
185
|
+
return value if value.is_a?(ReturnsFromArg)
|
|
186
|
+
return new_from_hash(value) if value.is_a?(Hash)
|
|
187
|
+
|
|
188
|
+
raise ArgumentError,
|
|
189
|
+
"Macro::HeredocTemplate::Emit#returns_from_arg must be a Hash or ReturnsFromArg, " \
|
|
190
|
+
"got #{value.inspect}"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def self.new_from_hash(hash)
|
|
194
|
+
position = hash[:position] || hash["position"]
|
|
195
|
+
lookup_via = hash[:lookup_via] || hash["lookup_via"]
|
|
196
|
+
unless lookup_via.is_a?(Hash)
|
|
197
|
+
raise ArgumentError,
|
|
198
|
+
"Macro::HeredocTemplate::Emit#returns_from_arg requires a `lookup_via:` Hash, " \
|
|
199
|
+
"got #{hash.inspect}"
|
|
200
|
+
end
|
|
201
|
+
new(
|
|
202
|
+
position: position,
|
|
203
|
+
plugin_id: lookup_via[:plugin_id] || lookup_via["plugin_id"],
|
|
204
|
+
fact: lookup_via[:fact] || lookup_via["fact"]
|
|
205
|
+
)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def initialize(position:, plugin_id:, fact:)
|
|
209
|
+
validate_position!(position)
|
|
210
|
+
validate_plugin_id!(plugin_id)
|
|
211
|
+
validate_fact!(fact)
|
|
212
|
+
|
|
213
|
+
@position = position
|
|
214
|
+
@plugin_id = plugin_id.dup.freeze
|
|
215
|
+
@fact = fact.to_sym
|
|
216
|
+
freeze
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def to_h
|
|
220
|
+
{
|
|
221
|
+
"position" => position,
|
|
222
|
+
"lookup_via" => {
|
|
223
|
+
"plugin_id" => plugin_id,
|
|
224
|
+
"fact" => fact.to_s
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def ==(other)
|
|
230
|
+
other.is_a?(ReturnsFromArg) && to_h == other.to_h
|
|
231
|
+
end
|
|
232
|
+
alias eql? ==
|
|
233
|
+
|
|
234
|
+
def hash
|
|
235
|
+
to_h.hash
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
private
|
|
239
|
+
|
|
240
|
+
def validate_position!(value)
|
|
241
|
+
return if value.is_a?(Integer) && value >= 0
|
|
242
|
+
|
|
243
|
+
raise ArgumentError,
|
|
244
|
+
"ReturnsFromArg#position must be a non-negative Integer, got #{value.inspect}"
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def validate_plugin_id!(value)
|
|
248
|
+
return if value.is_a?(String) && !value.empty?
|
|
249
|
+
|
|
250
|
+
raise ArgumentError,
|
|
251
|
+
"ReturnsFromArg#plugin_id must be a non-empty String, got #{value.inspect}"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def validate_fact!(value)
|
|
255
|
+
return if value.is_a?(Symbol) || (value.is_a?(String) && !value.empty?)
|
|
256
|
+
|
|
257
|
+
raise ArgumentError,
|
|
258
|
+
"ReturnsFromArg#fact must be a Symbol or non-empty String, got #{value.inspect}"
|
|
149
259
|
end
|
|
150
260
|
end
|
|
151
261
|
|
|
@@ -188,7 +298,11 @@ module Rigor
|
|
|
188
298
|
case entry
|
|
189
299
|
when Emit then entry
|
|
190
300
|
when Hash
|
|
191
|
-
Emit.new(
|
|
301
|
+
Emit.new(
|
|
302
|
+
name: entry[:name] || entry["name"],
|
|
303
|
+
returns: entry[:returns] || entry["returns"],
|
|
304
|
+
returns_from_arg: entry[:returns_from_arg] || entry["returns_from_arg"]
|
|
305
|
+
)
|
|
192
306
|
else
|
|
193
307
|
raise ArgumentError,
|
|
194
308
|
"Plugin::Macro::HeredocTemplate##{label} entry must be an Emit or Hash, " \
|
|
@@ -83,7 +83,7 @@ module Rigor
|
|
|
83
83
|
# This file ships the value class only. Slice 3b wires the
|
|
84
84
|
# scanner that walks Tier B call sites + the per-method
|
|
85
85
|
# explosion via `SyntheticMethodIndex`; slice 3c authors
|
|
86
|
-
# `
|
|
86
|
+
# `plugins/rigor-devise/` model side as the worked consumer.
|
|
87
87
|
class TraitRegistry
|
|
88
88
|
REST_POSITION = :rest
|
|
89
89
|
|