rigortype 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +36 -50
  3. data/lib/rigor/analysis/buffer_binding.rb +36 -0
  4. data/lib/rigor/analysis/check_rules.rb +11 -1
  5. data/lib/rigor/analysis/dependency_source_inference/index.rb +14 -1
  6. data/lib/rigor/analysis/dependency_source_inference/return_type_heuristic.rb +105 -0
  7. data/lib/rigor/analysis/dependency_source_inference/walker.rb +32 -12
  8. data/lib/rigor/analysis/project_scan.rb +39 -0
  9. data/lib/rigor/analysis/runner.rb +309 -22
  10. data/lib/rigor/analysis/worker_session.rb +14 -2
  11. data/lib/rigor/builtins/hkt_builtins.rb +342 -0
  12. data/lib/rigor/builtins/static_return_refinements.rb +120 -0
  13. data/lib/rigor/cache/store.rb +33 -3
  14. data/lib/rigor/cli/lsp_command.rb +129 -0
  15. data/lib/rigor/cli/type_of_command.rb +44 -5
  16. data/lib/rigor/cli.rb +74 -12
  17. data/lib/rigor/configuration.rb +38 -2
  18. data/lib/rigor/environment/hkt_registry_holder.rb +33 -0
  19. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  20. data/lib/rigor/environment/rbs_loader.rb +45 -2
  21. data/lib/rigor/environment/reporters.rb +40 -0
  22. data/lib/rigor/environment.rb +106 -9
  23. data/lib/rigor/inference/acceptance.rb +48 -3
  24. data/lib/rigor/inference/expression_typer.rb +47 -0
  25. data/lib/rigor/inference/hkt_body.rb +171 -0
  26. data/lib/rigor/inference/hkt_body_parser.rb +363 -0
  27. data/lib/rigor/inference/hkt_reducer.rb +256 -0
  28. data/lib/rigor/inference/hkt_registry.rb +223 -0
  29. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +125 -30
  30. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +87 -0
  31. data/lib/rigor/inference/method_dispatcher.rb +154 -3
  32. data/lib/rigor/inference/project_patched_methods.rb +70 -0
  33. data/lib/rigor/inference/project_patched_scanner.rb +210 -0
  34. data/lib/rigor/inference/scope_indexer.rb +156 -12
  35. data/lib/rigor/inference/statement_evaluator.rb +106 -6
  36. data/lib/rigor/inference/synthetic_method_scanner.rb +94 -16
  37. data/lib/rigor/language_server/buffer_table.rb +63 -0
  38. data/lib/rigor/language_server/completion_provider.rb +438 -0
  39. data/lib/rigor/language_server/debouncer.rb +86 -0
  40. data/lib/rigor/language_server/diagnostic_publisher.rb +167 -0
  41. data/lib/rigor/language_server/document_symbol_provider.rb +142 -0
  42. data/lib/rigor/language_server/folding_range_provider.rb +75 -0
  43. data/lib/rigor/language_server/hover_provider.rb +74 -0
  44. data/lib/rigor/language_server/hover_renderer.rb +312 -0
  45. data/lib/rigor/language_server/loop.rb +71 -0
  46. data/lib/rigor/language_server/project_context.rb +145 -0
  47. data/lib/rigor/language_server/selection_range_provider.rb +93 -0
  48. data/lib/rigor/language_server/server.rb +384 -0
  49. data/lib/rigor/language_server/signature_help_provider.rb +249 -0
  50. data/lib/rigor/language_server/synchronized_writer.rb +28 -0
  51. data/lib/rigor/language_server/uri.rb +40 -0
  52. data/lib/rigor/language_server.rb +29 -0
  53. data/lib/rigor/plugin/base.rb +63 -0
  54. data/lib/rigor/plugin/macro/heredoc_template.rb +125 -11
  55. data/lib/rigor/plugin/manifest.rb +54 -7
  56. data/lib/rigor/plugin/registry.rb +19 -0
  57. data/lib/rigor/rbs_extended/hkt_directives.rb +326 -0
  58. data/lib/rigor/rbs_extended.rb +82 -2
  59. data/lib/rigor/sig_gen/generator.rb +12 -3
  60. data/lib/rigor/type/app.rb +107 -0
  61. data/lib/rigor/type.rb +1 -0
  62. data/lib/rigor/version.rb +1 -1
  63. data/sig/rigor/environment.rbs +8 -4
  64. data/sig/rigor/inference.rbs +2 -0
  65. data/sig/rigor.rbs +3 -1
  66. metadata +54 -1
@@ -0,0 +1,342 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../inference/hkt_registry"
4
+ require_relative "../inference/hkt_body"
5
+ require_relative "../inference/hkt_body_parser"
6
+
7
+ module Rigor
8
+ module Builtins
9
+ # ADR-20 slices 2c + 3 — Rigor-bundled Lightweight HKT
10
+ # registrations that ship with every analyzer instance.
11
+ # The set is intentionally small at v0.1.x: only the URIs
12
+ # whose payoff justifies hardcoded definitions. Plugin
13
+ # authors register more URIs through their manifests; user
14
+ # `.rbs` overlays register through the
15
+ # `%a{rigor:v1:hkt_register}` /
16
+ # `%a{rigor:v1:hkt_define}` annotations Slice 1 ships.
17
+ #
18
+ # Today's contents:
19
+ #
20
+ # - `json::value[K]` — the recursive sum stdlib's
21
+ # `JSON.parse` returns. Body:
22
+ #
23
+ # nil | true | false | Integer | Float | String
24
+ # | Array[App[json::value, K]]
25
+ # | Hash[K, App[json::value, K]]
26
+ #
27
+ # The reducer handles the self-recursive `App` nodes via
28
+ # lazy "tying-the-knot" (see {HktReducer}). `K = String`
29
+ # matches stdlib's default key handling; `K = Symbol`
30
+ # matches `symbolize_names: true`.
31
+ module HktBuiltins
32
+ module_function
33
+
34
+ # Built via the body-string parser (slice 2b/2c) so the
35
+ # bundled overlay exercises the same authoring surface
36
+ # third-party plugins use. The body matches what user
37
+ # `.rbs` overlays would write through a
38
+ # `%a{rigor:v1:hkt_define: ...body=...}` annotation.
39
+ JSON_VALUE_BODY = "nil | true | false | Integer | Float | String | " \
40
+ "Array[App[json::value, K]] | Hash[K, App[json::value, K]]"
41
+ private_constant :JSON_VALUE_BODY
42
+
43
+ def json_value_body_tree
44
+ Rigor::Inference::HktBodyParser.parse(JSON_VALUE_BODY, params: [:K])
45
+ end
46
+
47
+ # `csv::parsed[K]` — `Array[Array[K | nil]]` (CSV.parse's
48
+ # no-headers shape: an Array of rows; each row is an
49
+ # Array of optionally-nil cell values). When
50
+ # `headers: true` the runtime returns a `CSV::Table` /
51
+ # `CSV::Row` shape instead — that case is NOT covered
52
+ # by the bundled override (CSV::Row is its own class
53
+ # with Hash + Array access; a future slice may add a
54
+ # separate URI or a discriminator hook for it).
55
+ CSV_PARSED_BODY = "Array[Array[K | nil]]"
56
+ private_constant :CSV_PARSED_BODY
57
+
58
+ def csv_parsed_body_tree
59
+ Rigor::Inference::HktBodyParser.parse(CSV_PARSED_BODY, params: [:K])
60
+ end
61
+
62
+ def json_value_registration
63
+ Rigor::Inference::HktRegistry::Registration.new(
64
+ uri: :"json::value",
65
+ arity: 1,
66
+ variance: [:out],
67
+ bound: Rigor::Type::Combinator.untyped
68
+ )
69
+ end
70
+
71
+ def json_value_definition
72
+ Rigor::Inference::HktRegistry.definition_with_body_tree(
73
+ uri: :"json::value",
74
+ params: [:K],
75
+ body_tree: json_value_body_tree,
76
+ source_path: __FILE__,
77
+ source_line: __LINE__ - 5
78
+ )
79
+ end
80
+
81
+ def csv_parsed_registration
82
+ Rigor::Inference::HktRegistry::Registration.new(
83
+ uri: :"csv::parsed",
84
+ arity: 1,
85
+ variance: [:out],
86
+ bound: Rigor::Type::Combinator.untyped
87
+ )
88
+ end
89
+
90
+ def csv_parsed_definition
91
+ Rigor::Inference::HktRegistry.definition_with_body_tree(
92
+ uri: :"csv::parsed",
93
+ params: [:K],
94
+ body_tree: csv_parsed_body_tree,
95
+ source_path: __FILE__,
96
+ source_line: __LINE__ - 5
97
+ )
98
+ end
99
+
100
+ # @return [Rigor::Inference::HktRegistry] frozen registry
101
+ # pre-seeded with all bundled HKT registrations +
102
+ # bodies. Allocated fresh each call rather than
103
+ # memoised — memoisation through a module-level
104
+ # `@registry` ivar surfaces a `Ractor::IsolationError`
105
+ # in pool workers (the ivar's contents include
106
+ # `HktBody::AppRef` Symbol-keyed structures that the
107
+ # current Ractor shareability audit hasn't yet been
108
+ # walked through). The registry is small enough that
109
+ # per-Environment construction is acceptable; an
110
+ # eager-frozen constant is a future optimisation
111
+ # once ADR-15 phase 4b.x covers the dependency graph.
112
+ def registry
113
+ Rigor::Inference::HktRegistry.new(
114
+ registrations: [json_value_registration, csv_parsed_registration],
115
+ definitions: [json_value_definition, csv_parsed_definition]
116
+ )
117
+ end
118
+
119
+ # ADR-20 slice 3 — hardcoded `(class_name, method_name,
120
+ # kind) => HKT application` table consulted by the
121
+ # dispatcher's new HKT-builtin tier. Sits ABOVE
122
+ # `RbsDispatch.try_dispatch` so a known stdlib method
123
+ # (`JSON.parse`, `JSON.parse!`) gets the reduced HKT
124
+ # type instead of the upstream rbs gem's `untyped`
125
+ # return. The annotation-based `%a{rigor:v1:return:
126
+ # App[...]}` path (parsed by
127
+ # `RbsExtended.parse_return_type_override`) is the
128
+ # general extension surface for user-authored sigs;
129
+ # this table is the Rigor-bundled shortcut for the
130
+ # handful of stdlib methods whose RBS declarations
131
+ # cannot be cleanly overridden via RBS overlay merging.
132
+ #
133
+ # Each entry maps to a hash with `:uri` and `:args`
134
+ # (an array of Ruby class names). The dispatcher
135
+ # builds `Type::App.new(uri, args.map { Nominal })`,
136
+ # then reduces via the env's `hkt_registry` so the
137
+ # caller observes the unfolded form
138
+ # (`Union[nil, true, false, ..., Array[App[json::value,
139
+ # String]], Hash[String, App[json::value, String]]]`)
140
+ # rather than the opaque carrier.
141
+ JSON_VALUE_SPEC = {
142
+ uri: :"json::value",
143
+ args: ["String"],
144
+ discriminator: :json_symbolize_names,
145
+ post_reduce: nil
146
+ }.freeze
147
+ private_constant :JSON_VALUE_SPEC
148
+
149
+ # YAML / Psych.safe_load reuse the json::value reducer
150
+ # for the JSON-equivalent leaf set BUT additionally
151
+ # honour `permitted_classes: [<Class>, ...]` literal
152
+ # Array arguments, unioning each permitted class as an
153
+ # extra arm of the result. Slice 2c-bis behaviour.
154
+ YAML_SAFE_VALUE_SPEC = {
155
+ uri: :"json::value",
156
+ args: ["String"],
157
+ discriminator: :json_symbolize_names,
158
+ post_reduce: :yaml_permitted_classes
159
+ }.freeze
160
+ private_constant :YAML_SAFE_VALUE_SPEC
161
+
162
+ CSV_PARSED_SPEC = {
163
+ uri: :"csv::parsed",
164
+ args: ["String"],
165
+ discriminator: nil,
166
+ post_reduce: nil
167
+ }.freeze
168
+ private_constant :CSV_PARSED_SPEC
169
+
170
+ METHOD_RETURN_OVERRIDES = {
171
+ # JSON — stdlib's `json` library. Upstream rbs declares
172
+ # `(string, ?options) -> untyped`; the HKT-builtin tier
173
+ # tightens to the recursive `json::value[K]` union.
174
+ # `load_file` / `load_file!` share the `?options` slot
175
+ # so the `symbolize_names: true` discriminator applies
176
+ # to them too (just like `parse` / `load`).
177
+ ["JSON", :parse, :singleton] => JSON_VALUE_SPEC,
178
+ ["JSON", :parse!, :singleton] => JSON_VALUE_SPEC,
179
+ ["JSON", :load, :singleton] => JSON_VALUE_SPEC,
180
+ ["JSON", :load_file, :singleton] => JSON_VALUE_SPEC,
181
+ ["JSON", :load_file!, :singleton] => JSON_VALUE_SPEC,
182
+ # YAML.safe_load / Psych.safe_load — default
183
+ # `permitted_classes: []` admits exactly the JSON
184
+ # vocabulary (nil / true / false / Integer / Float /
185
+ # String / Array / Hash), so the json::value tree
186
+ # also describes them. When the call passes a literal
187
+ # `permitted_classes: [Date, Symbol, ...]` Array, the
188
+ # `:yaml_permitted_classes` post_reduce unions each
189
+ # named class into the result. Non-literal options
190
+ # (a variable, a constant reference, a `+ classes`
191
+ # concat) silently no-op and the caller observes the
192
+ # base json::value envelope only. YAML.load /
193
+ # YAML.unsafe_load deliberately stay out of the
194
+ # override table — they can return ANY Ruby object
195
+ # and have no useful HKT envelope.
196
+ ["YAML", :safe_load, :singleton] => YAML_SAFE_VALUE_SPEC,
197
+ ["YAML", :safe_load_file, :singleton] => YAML_SAFE_VALUE_SPEC,
198
+ ["Psych", :safe_load, :singleton] => YAML_SAFE_VALUE_SPEC,
199
+ ["Psych", :safe_load_file, :singleton] => YAML_SAFE_VALUE_SPEC,
200
+ # CSV.parse / CSV.read — no-headers shape only.
201
+ # Upstream rbs declares broader return shapes but
202
+ # the common case is `Array[Array[String?]]` which
203
+ # the `csv::parsed[String]` URI matches. The
204
+ # `headers: true` shape (`CSV::Table` of `CSV::Row`)
205
+ # is NOT covered — calls passing the option fall
206
+ # through to the upstream RBS type. CSV.foreach also
207
+ # falls through (it yields rows rather than
208
+ # returning a typed structure).
209
+ ["CSV", :parse, :singleton] => CSV_PARSED_SPEC,
210
+ ["CSV", :read, :singleton] => CSV_PARSED_SPEC
211
+ }.freeze
212
+
213
+ # @return [Rigor::Type, nil] the reduced HKT type for
214
+ # the given (class_name, method_name, kind) triple,
215
+ # or `nil` when no built-in override is registered.
216
+ # When `arg_types` is supplied AND the entry carries a
217
+ # `:discriminator` symbol, the discriminator may swap
218
+ # the spec's default args for an alternate (e.g.
219
+ # `JSON.parse(str, symbolize_names: true)` discriminates
220
+ # `K = Symbol` instead of the default `K = String`).
221
+ def method_return_override(class_name:, method_name:, kind:, arg_types: nil, hkt_registry: nil)
222
+ spec = METHOD_RETURN_OVERRIDES[[class_name, method_name.to_sym, kind]]
223
+ return nil unless spec
224
+
225
+ args = discriminated_args(spec, arg_types)
226
+ registration = hkt_registry&.registration(spec[:uri])
227
+ bound = registration&.bound || Rigor::Type::Combinator.untyped
228
+ app = Rigor::Type::App.new(spec[:uri], args, bound: bound)
229
+
230
+ reduced =
231
+ if hkt_registry.nil? || !hkt_registry.defined?(spec[:uri])
232
+ app
233
+ else
234
+ hkt_registry.reduce(app) || app
235
+ end
236
+
237
+ apply_post_reduce(spec[:post_reduce], reduced, arg_types)
238
+ end
239
+
240
+ # Per-spec discriminator dispatch. Slice 3 ships one
241
+ # built-in discriminator (`json_symbolize_names`) that
242
+ # observes the optional 2nd argument's `HashShape` for a
243
+ # literal `symbolize_names: true` entry. Plugin / Rigor-
244
+ # bundled callers wanting their own discriminators add a
245
+ # branch here.
246
+ def discriminated_args(spec, arg_types)
247
+ default_args = spec[:args].map { |n| Rigor::Type::Nominal.new(n) }
248
+ return default_args if arg_types.nil?
249
+ return default_args unless spec[:discriminator] == :json_symbolize_names
250
+ return default_args unless json_symbolize_names?(arg_types)
251
+
252
+ [Rigor::Type::Nominal.new("Symbol")]
253
+ end
254
+
255
+ # Returns true iff the call-site's 2nd argument is a
256
+ # `Type::HashShape` carrying a literal
257
+ # `symbolize_names: true` entry. Anything else
258
+ # (no second arg, non-HashShape, missing key, non-literal
259
+ # `true`) returns false so the default `K = String`
260
+ # branch wins.
261
+ def json_symbolize_names?(arg_types)
262
+ return false unless arg_types.is_a?(Array) && arg_types.size >= 2
263
+
264
+ opts = arg_types[1]
265
+ return false unless opts.is_a?(Rigor::Type::HashShape)
266
+
267
+ value = opts.pairs[:symbolize_names] || opts.pairs["symbolize_names"]
268
+ value.is_a?(Rigor::Type::Constant) && value.value == true
269
+ end
270
+
271
+ # Slice 2c-bis — post-reduce hook. Receives the already-
272
+ # reduced `Type` and the call-site's `arg_types`; returns
273
+ # a (possibly augmented) `Type`. `kind = nil` is the
274
+ # identity (passes the reduced type through unchanged).
275
+ # Only `:yaml_permitted_classes` is implemented today;
276
+ # plugin / Rigor-bundled callers wanting their own
277
+ # post-reduce hooks add a branch here.
278
+ def apply_post_reduce(kind, reduced, arg_types)
279
+ case kind
280
+ when :yaml_permitted_classes
281
+ augment_with_yaml_permitted_classes(reduced, arg_types)
282
+ else
283
+ # `nil` (no post-reduce declared) and any future
284
+ # unrecognised kind both pass the reduced type
285
+ # through unchanged. Unknown kinds are silently
286
+ # tolerated rather than raised because adding a
287
+ # new kind on a Rigor upgrade should not crash a
288
+ # stale METHOD_RETURN_OVERRIDES entry on the
289
+ # caller side.
290
+ reduced
291
+ end
292
+ end
293
+
294
+ # Inspects arg_types for a `permitted_classes: [<Class>,
295
+ # ...]` literal Array in the options Hash and unions
296
+ # each named class into the reduced result. Non-literal
297
+ # `permitted_classes:` values (a variable, a constant
298
+ # reference, a concat) silently no-op and the caller
299
+ # observes the base json::value envelope only. Defensive
300
+ # against the various ways Ruby literal arrays surface
301
+ # as Rigor types: `Tuple[Singleton<Date>]` for a single
302
+ # element, `Tuple[Singleton<Date>, Singleton<Symbol>]`
303
+ # for multiple, `Nominal[Array, [Singleton<...>]]` if
304
+ # the analyzer widened (rare for literal arrays).
305
+ def augment_with_yaml_permitted_classes(reduced, arg_types)
306
+ return reduced unless arg_types.is_a?(Array) && arg_types.size >= 2
307
+
308
+ opts = arg_types[1]
309
+ return reduced unless opts.is_a?(Rigor::Type::HashShape)
310
+
311
+ value = opts.pairs[:permitted_classes] || opts.pairs["permitted_classes"]
312
+ return reduced if value.nil?
313
+
314
+ extras = permitted_class_nominals(value)
315
+ return reduced if extras.empty?
316
+
317
+ Rigor::Type::Combinator.union(reduced, *extras)
318
+ end
319
+
320
+ # Extract Singleton-class elements from a Tuple or
321
+ # Array-shape carrier, mapping each to its Nominal
322
+ # counterpart. Returns an empty array when no static
323
+ # Singletons are reachable (e.g. value is `Dynamic[T]`,
324
+ # element types are non-Singleton, etc.).
325
+ def permitted_class_nominals(value)
326
+ candidates =
327
+ if value.is_a?(Rigor::Type::Tuple)
328
+ value.elements
329
+ elsif value.is_a?(Rigor::Type::Nominal) && value.class_name == "Array" && value.type_args.size == 1
330
+ element = value.type_args.first
331
+ element.is_a?(Rigor::Type::Union) ? element.members : [element]
332
+ else
333
+ []
334
+ end
335
+
336
+ candidates.filter_map do |c|
337
+ c.is_a?(Rigor::Type::Singleton) ? Rigor::Type::Nominal.new(c.class_name) : nil
338
+ end
339
+ end
340
+ end
341
+ end
342
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../type"
4
+
5
+ module Rigor
6
+ module Builtins
7
+ # Static return-type refinements for stdlib (or other built-in)
8
+ # methods whose upstream RBS signature is broader than the
9
+ # method's documented behaviour and where adding a
10
+ # `%a{rigor:v1:return: ...}` annotation upstream is impractical
11
+ # (the RBS lives in the vendored `ruby/rbs` submodule).
12
+ #
13
+ # This tier sits in `MethodDispatcher.dispatch` between the
14
+ # HKT-builtin tier (which handles parametric / shape-bearing
15
+ # returns like `JSON.parse`'s `json::value`) and the RBS
16
+ # dispatch tier (the canonical lookup). It is consulted only
17
+ # when the method name and arg shape match an entry in the
18
+ # table below, so the standard RBS path stays in charge of
19
+ # every other call.
20
+ #
21
+ # Match policy:
22
+ #
23
+ # - Entries are keyed by `(owner_class_name, method_name,
24
+ # kind)`. `owner_class_name` is the class that *defines*
25
+ # the method (e.g., `"Kernel"`), not necessarily the
26
+ # receiver's static class — Kernel methods are mixed into
27
+ # every non-BasicObject class, so a `__dir__` call on any
28
+ # receiver routes here.
29
+ # - `kind: :both` matches both the singleton-receiver
30
+ # shape (`Kernel.__dir__`, `Singleton[Kernel]` receiver)
31
+ # AND the instance-receiver shape (an implicit-self call
32
+ # like `__dir__` inside any class body, or `obj.__dir__`
33
+ # on an instance).
34
+ # - `kind: :singleton` / `kind: :instance` restrict the
35
+ # match to one of the two shapes.
36
+ # - The handler is called with `(arg_types)` so future
37
+ # entries can refine based on argument types (e.g. a
38
+ # `File.expand_path(string)` entry that returns
39
+ # `non-empty-string` regardless of the upstream return).
40
+ #
41
+ # The override fires ABOVE RBS dispatch — if RBS would have
42
+ # returned a wider type (`String?` for `Kernel#__dir__`), the
43
+ # override returns the refined union (`non-empty-string | nil`)
44
+ # instead. RBS erasure of the refined return goes back to the
45
+ # original upstream shape, so downstream RBS-shaped observers
46
+ # see no difference.
47
+ module StaticReturnRefinements
48
+ # Pre-built carrier reused across calls so structural
49
+ # equality matches across analyzer invocations.
50
+ NON_EMPTY_STRING_OR_NIL = Type::Combinator.union(
51
+ Type::Combinator.non_empty_string,
52
+ Type::Combinator.constant_of(nil)
53
+ ).freeze
54
+ private_constant :NON_EMPTY_STRING_OR_NIL
55
+
56
+ # `Kernel#__dir__` returns the canonical directory of the
57
+ # source file the call appears in, or `nil` when the file
58
+ # is invalid / not available (typically `-e` and similar
59
+ # one-liner contexts). When non-nil the value is always a
60
+ # filesystem-canonical path — never the empty string — so
61
+ # `non-empty-string` is exact.
62
+ KERNEL_DIR = ->(_arg_types) { NON_EMPTY_STRING_OR_NIL }
63
+ private_constant :KERNEL_DIR
64
+
65
+ # Frozen ((owner_class_name, method_name, kind) => handler)
66
+ # table. The kind tag is `:both`, `:singleton`, or
67
+ # `:instance`. New entries SHOULD prefer `:both` unless the
68
+ # singleton- and instance-side shapes genuinely differ.
69
+ OVERRIDES = {
70
+ ["Kernel", :__dir__, :both] => KERNEL_DIR
71
+ }.freeze
72
+ private_constant :OVERRIDES
73
+
74
+ # Looks up a refined return type for the given call.
75
+ #
76
+ # @param owner_class_name [String, nil] the class on which
77
+ # the method is defined (e.g., `"Kernel"`). Pass `nil`
78
+ # when the caller hasn't resolved a defining owner yet —
79
+ # the lookup will then fall back to matching by
80
+ # `(method_name, kind)` against entries whose owner is
81
+ # currently in the table.
82
+ # @param method_name [Symbol]
83
+ # @param kind [Symbol] one of `:singleton`, `:instance`. The
84
+ # caller passes the shape of the actual call site; the
85
+ # table stores `:both` for entries that match either.
86
+ # @param arg_types [Array<Rigor::Type>] positional argument
87
+ # types. Forwarded to the handler so future entries can
88
+ # discriminate on argument shape.
89
+ # @return [Rigor::Type, nil] the refined return type, or
90
+ # `nil` when no override matches.
91
+ def self.lookup(owner_class_name:, method_name:, kind:, arg_types: [])
92
+ return nil if owner_class_name.nil?
93
+
94
+ method_sym = method_name.to_sym
95
+ handler = OVERRIDES[[owner_class_name, method_sym, :both]] ||
96
+ OVERRIDES[[owner_class_name, method_sym, kind]]
97
+ handler&.call(arg_types)
98
+ end
99
+
100
+ # Indexed view by `(method_name, kind)` — used by the
101
+ # dispatcher when the receiver's owner is not yet resolved
102
+ # but the method name alone uniquely identifies a stdlib
103
+ # override (today: `__dir__` → Kernel). The table is small
104
+ # and the index rebuild cost trivial, but precomputing keeps
105
+ # `dispatch`'s hot path free of an O(n) scan.
106
+ OWNERS_BY_METHOD = OVERRIDES.each_with_object({}) do |((owner, mname, _kind), _h), acc|
107
+ acc[mname] ||= []
108
+ acc[mname] << owner unless acc[mname].include?(owner)
109
+ end.freeze
110
+ private_constant :OWNERS_BY_METHOD
111
+
112
+ # @return [Array<String>] the candidate owner class names
113
+ # for a bare method-name lookup. Empty when no override
114
+ # names this method.
115
+ def self.owners_for(method_name)
116
+ OWNERS_BY_METHOD[method_name.to_sym] || []
117
+ end
118
+ end
119
+ end
120
+ end
@@ -31,8 +31,22 @@ module Rigor
31
31
 
32
32
  VALID_PRODUCER_ID = /\A[a-z][a-z0-9._-]*\z/
33
33
 
34
- def initialize(root:)
34
+ # @param root [String] cache root directory.
35
+ # @param read_only [Boolean] when true, every disk-side
36
+ # side-effect is suppressed: `fetch_or_compute` still
37
+ # reads existing entries (hits) and still runs the
38
+ # producer block on miss, but it does NOT write the
39
+ # produced value to disk, does NOT update the
40
+ # `schema_version.txt` marker, and does NOT touch the
41
+ # on-disk root directory. The in-process memo is still
42
+ # populated so repeated lookups within the same run stay
43
+ # cheap. Used by editor mode so multiple buffer-mode
44
+ # invocations can read from the same cache concurrently
45
+ # without churning it. See
46
+ # `docs/design/20260516-editor-mode.md` § "Cache behaviour".
47
+ def initialize(root:, read_only: false)
35
48
  @root = root.to_s.dup.freeze
49
+ @read_only = read_only
36
50
  @hits = 0
37
51
  @misses = 0
38
52
  @writes = 0
@@ -59,6 +73,13 @@ module Rigor
59
73
 
60
74
  attr_reader :root
61
75
 
76
+ # @return [Boolean] whether this Store suppresses disk writes
77
+ # (`schema_version.txt`, entry creation). Reads are
78
+ # unaffected.
79
+ def read_only?
80
+ @read_only
81
+ end
82
+
62
83
  # Returns a frozen snapshot of this Store's per-run hit / miss /
63
84
  # write counters. The bookkeeping is in-memory only — every new
64
85
  # `Store.new` starts at zero — so the counters reflect activity
@@ -167,10 +188,10 @@ module Rigor
167
188
  end
168
189
 
169
190
  value = block.call
170
- write_entry(path, descriptor, value, serialize: serialize)
191
+ write_entry(path, descriptor, value, serialize: serialize) unless @read_only
171
192
  @monitor.synchronize do
172
193
  record(:misses, producer_id)
173
- record(:writes, producer_id)
194
+ record(:writes, producer_id) unless @read_only
174
195
  @memo[memo_key] = value
175
196
  end
176
197
  value
@@ -302,6 +323,15 @@ module Rigor
302
323
  end
303
324
 
304
325
  def ensure_schema_version!
326
+ # Read-only stores never touch the cache root — no mkdir,
327
+ # no marker write, no destructive clear on schema
328
+ # mismatch. A stale or wrong-schema marker simply yields
329
+ # nothing back (entries read through the version check
330
+ # are content-keyed, so a write under the new schema
331
+ # never collides with a read under the old). The next
332
+ # writable run will repair the cache.
333
+ return if @read_only
334
+
305
335
  FileUtils.mkdir_p(@root)
306
336
  marker = File.join(@root, "schema_version.txt")
307
337
  current = Descriptor::SCHEMA_VERSION.to_s
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optionparser"
4
+
5
+ module Rigor
6
+ class CLI
7
+ # Executes the `rigor lsp` command.
8
+ #
9
+ # See `docs/design/20260517-language-server.md` for the design.
10
+ # Slice 1 (this commit) ships the CLI subcommand entry point.
11
+ # The actual stdio JSON-RPC reader / writer is queued for slice 2;
12
+ # invoking `rigor lsp` at slice 1 returns immediately after
13
+ # validating the transport flag.
14
+ class LspCommand
15
+ USAGE = "Usage: rigor lsp [options]"
16
+
17
+ def initialize(argv:, out:, err:)
18
+ @argv = argv
19
+ @out = out
20
+ @err = err
21
+ end
22
+
23
+ # @return [Integer] CLI exit status.
24
+ def run
25
+ options = parse_options
26
+ return CLI::EXIT_USAGE if options == :usage_error
27
+
28
+ transport = options.fetch(:transport)
29
+ unless transport == "stdio"
30
+ @err.puts("rigor lsp: unsupported transport: #{transport.inspect} (only `stdio` is supported in v1)")
31
+ return CLI::EXIT_USAGE
32
+ end
33
+
34
+ require_relative "../language_server"
35
+ require_relative "../configuration"
36
+ require "language_server-protocol"
37
+
38
+ # STDIN is read frame-by-frame via the gem's `Io::Reader`;
39
+ # STDOUT is wrapped in `SynchronizedWriter` so concurrent
40
+ # writes from the main dispatch thread + the Debouncer's
41
+ # async threads don't interleave frames. The Loop runs
42
+ # until either STDIN hits EOF or `server.exited?`; the
43
+ # process then exits with the server's recorded code
44
+ # (0 after a clean shutdown+exit, 1 otherwise).
45
+ writer = LanguageServer::SynchronizedWriter.new(
46
+ ::LanguageServer::Protocol::Transport::Io::Writer.new($stdout)
47
+ )
48
+ server, loop_runner = build_server(writer: writer, config_path: options.fetch(:config))
49
+ loop_runner.run
50
+ server.exit_code || 0
51
+ end
52
+
53
+ private
54
+
55
+ # Builds the full collaborator graph from a fresh
56
+ # `Configuration` + `ProjectContext`. Returns `[server,
57
+ # loop]` so the caller drives the loop and reads
58
+ # `server.exit_code` for the process exit status.
59
+ def build_server(writer:, config_path:) # rubocop:disable Metrics/MethodLength
60
+ configuration = Configuration.load(config_path)
61
+ # ProjectContext caches Environment + Cache::Store across
62
+ # requests so hover / publish hit the warm path. Invalidated
63
+ # by `workspace/didChangeWatchedFiles` and
64
+ # `workspace/didChangeConfiguration`.
65
+ project_context = LanguageServer::ProjectContext.new(configuration: configuration)
66
+ # Single source of truth for buffer state — threaded to
67
+ # Server + all three providers.
68
+ buffer_table = LanguageServer::BufferTable.new
69
+ debouncer = LanguageServer::Debouncer.new
70
+ publisher = LanguageServer::DiagnosticPublisher.new(
71
+ writer: writer, buffer_table: buffer_table, project_context: project_context,
72
+ debouncer: debouncer, debounce_seconds: 0.2
73
+ )
74
+ server = LanguageServer::Server.new(
75
+ buffer_table: buffer_table,
76
+ publisher: publisher,
77
+ hover_provider: LanguageServer::HoverProvider.new(
78
+ buffer_table: buffer_table, project_context: project_context
79
+ ),
80
+ document_symbol_provider: LanguageServer::DocumentSymbolProvider.new(
81
+ buffer_table: buffer_table, project_context: project_context
82
+ ),
83
+ completion_provider: LanguageServer::CompletionProvider.new(
84
+ buffer_table: buffer_table, project_context: project_context
85
+ ),
86
+ signature_help_provider: LanguageServer::SignatureHelpProvider.new(
87
+ buffer_table: buffer_table, project_context: project_context
88
+ ),
89
+ folding_range_provider: LanguageServer::FoldingRangeProvider.new(
90
+ buffer_table: buffer_table, project_context: project_context
91
+ ),
92
+ selection_range_provider: LanguageServer::SelectionRangeProvider.new(
93
+ buffer_table: buffer_table, project_context: project_context
94
+ ),
95
+ project_context: project_context
96
+ )
97
+ loop_runner = LanguageServer::Loop.new(
98
+ reader: ::LanguageServer::Protocol::Transport::Io::Reader.new($stdin),
99
+ writer: writer,
100
+ server: server
101
+ )
102
+ [server, loop_runner]
103
+ end
104
+
105
+ def parse_options
106
+ options = { transport: "stdio", log: nil, config: nil }
107
+
108
+ parser = OptionParser.new do |opts|
109
+ opts.banner = USAGE
110
+ opts.on("--transport=NAME", "Transport (default: stdio; only stdio supported in v1)") do |value|
111
+ options[:transport] = value
112
+ end
113
+ opts.on("--log=PATH", "Write LSP wire log + server debug to PATH (default: stderr)") do |value|
114
+ options[:log] = value
115
+ end
116
+ opts.on("--config=PATH", "Path to the Rigor configuration file") do |value|
117
+ options[:config] = value
118
+ end
119
+ end
120
+ parser.parse!(@argv)
121
+ options
122
+ rescue OptionParser::ParseError => e
123
+ @err.puts(e.message)
124
+ @err.puts(USAGE)
125
+ :usage_error
126
+ end
127
+ end
128
+ end
129
+ end