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,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
@@ -0,0 +1,384 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../version"
4
+ require_relative "buffer_table"
5
+
6
+ module Rigor
7
+ module LanguageServer
8
+ # LSP server lifecycle state machine + JSON-RPC method dispatcher.
9
+ #
10
+ # Slice 1 (this commit) ships:
11
+ # - State machine: `:uninitialized` → `:initialized` → `:shutdown`
12
+ # → `:exited`.
13
+ # - Three lifecycle handlers: `initialize`, `shutdown`, `exit`.
14
+ # - {#dispatch} which routes (method, params) to the matching
15
+ # handler and returns the response payload (or `nil` for
16
+ # notifications). Out-of-state requests return the
17
+ # spec-defined `InvalidRequest` (-32002) / `MethodNotFound`
18
+ # (-32601) error shapes.
19
+ #
20
+ # Slice 2 wraps this dispatcher in a stdio JSON-RPC reader /
21
+ # writer so the CLI subcommand can serve real LSP clients.
22
+ # Slice 3+ adds document sync; slice 4+ adds publishDiagnostics;
23
+ # slice 5-8 add the rest of the v1 capability surface.
24
+ class Server # rubocop:disable Metrics/ClassLength
25
+ # JSON-RPC error codes per LSP spec § "Response Message".
26
+ ERROR_PARSE_ERROR = -32_700
27
+ ERROR_INVALID_REQUEST = -32_600
28
+ ERROR_METHOD_NOT_FOUND = -32_601
29
+ ERROR_INVALID_PARAMS = -32_602
30
+ ERROR_INTERNAL_ERROR = -32_603
31
+ # LSP-specific reserved codes.
32
+ ERROR_SERVER_NOT_INITIALIZED = -32_002
33
+ ERROR_INVALID_REQUEST_AFTER_SHUTDOWN = -32_600
34
+
35
+ # `TextDocumentSyncKind::Full = 1`. Slice 10 (deferred)
36
+ # promotes to `Incremental = 2`.
37
+ TEXT_DOCUMENT_SYNC_FULL = 1
38
+
39
+ # Methods callable BEFORE `initialize`. Per LSP spec § 3 only
40
+ # `initialize` and `exit` are allowed pre-initialization; every
41
+ # other request returns `ServerNotInitialized`. We also accept
42
+ # `shutdown` so a sequence like `initialize → shutdown → exit`
43
+ # (the conformance harness) round-trips even when the client
44
+ # skips real work.
45
+ PRE_INITIALIZE_METHODS = %w[initialize shutdown exit].freeze
46
+
47
+ attr_reader :state, :exit_code, :buffer_table, :publisher,
48
+ :hover_provider, :document_symbol_provider, :completion_provider,
49
+ :signature_help_provider, :folding_range_provider,
50
+ :selection_range_provider, :project_context
51
+
52
+ # @param completion_provider [Rigor::LanguageServer::CompletionProvider, nil]
53
+ # resolves `textDocument/completion`. Nil → `MethodNotFound`.
54
+ # @param signature_help_provider [Rigor::LanguageServer::SignatureHelpProvider, nil]
55
+ # resolves `textDocument/signatureHelp`. Nil → `MethodNotFound`.
56
+ # @param project_context [Rigor::LanguageServer::ProjectContext, nil]
57
+ # the per-session cache of `Environment` + `Cache::Store`
58
+ # the providers read on every request. When present,
59
+ # `workspace/didChangeWatchedFiles` and
60
+ # `workspace/didChangeConfiguration` invalidate the cache;
61
+ # nil means "no project context", which is the slice 1-6
62
+ # behaviour (each request rebuilds env from scratch).
63
+ def initialize(buffer_table: BufferTable.new, publisher: nil, # rubocop:disable Metrics/ParameterLists
64
+ hover_provider: nil, document_symbol_provider: nil,
65
+ completion_provider: nil, signature_help_provider: nil,
66
+ folding_range_provider: nil, selection_range_provider: nil,
67
+ project_context: nil)
68
+ @state = :uninitialized
69
+ @exit_code = nil
70
+ @buffer_table = buffer_table
71
+ @publisher = publisher
72
+ @hover_provider = hover_provider
73
+ @document_symbol_provider = document_symbol_provider
74
+ @completion_provider = completion_provider
75
+ @signature_help_provider = signature_help_provider
76
+ @folding_range_provider = folding_range_provider
77
+ @selection_range_provider = selection_range_provider
78
+ @project_context = project_context
79
+ end
80
+
81
+ # @return [Boolean] true once the client has called `exit` and
82
+ # the server has set its terminal exit code. The CLI loop
83
+ # reads this between dispatches to know when to stop.
84
+ def exited?
85
+ @state == :exited
86
+ end
87
+
88
+ # Routes one LSP method call.
89
+ #
90
+ # @param method [String] the LSP method name (e.g. "initialize").
91
+ # @param params [Hash, nil] the LSP `params` payload (Hash for
92
+ # request / notification methods; nil for the empty case).
93
+ # @return [Hash, nil] one of:
94
+ # - the response result Hash for request methods,
95
+ # - nil for notification methods,
96
+ # - { error: { code:, message: } } for state / shape errors.
97
+ def dispatch(method, params = nil) # rubocop:disable Metrics/CyclomaticComplexity
98
+ return state_violation_response(method) unless method_allowed_in_state?(method)
99
+
100
+ case method
101
+ when "initialize" then handle_initialize(params)
102
+ when "initialized" then handle_initialized
103
+ when "shutdown" then handle_shutdown
104
+ when "exit" then handle_exit
105
+ when "textDocument/didOpen" then handle_did_open(params)
106
+ when "textDocument/didChange" then handle_did_change(params)
107
+ when "textDocument/didClose" then handle_did_close(params)
108
+ when "textDocument/hover" then handle_hover(params)
109
+ when "textDocument/documentSymbol" then handle_document_symbol(params)
110
+ when "textDocument/completion" then handle_completion(params)
111
+ when "textDocument/signatureHelp" then handle_signature_help(params)
112
+ when "textDocument/foldingRange" then handle_folding_range(params)
113
+ when "textDocument/selectionRange" then handle_selection_range(params)
114
+ when "workspace/didChangeWatchedFiles" then handle_did_change_watched_files(params)
115
+ when "workspace/didChangeConfiguration" then handle_did_change_configuration(params)
116
+ else
117
+ method_not_found(method)
118
+ end
119
+ end
120
+
121
+ private
122
+
123
+ def method_allowed_in_state?(method)
124
+ case @state
125
+ when :uninitialized then PRE_INITIALIZE_METHODS.include?(method)
126
+ when :initialized then method != "initialize"
127
+ when :shutdown then method == "exit"
128
+ when :exited then false
129
+ end
130
+ end
131
+
132
+ def state_violation_response(method)
133
+ case @state
134
+ when :uninitialized
135
+ rpc_error(
136
+ ERROR_SERVER_NOT_INITIALIZED,
137
+ "method #{method.inspect} requires `initialize` first"
138
+ )
139
+ when :initialized
140
+ rpc_error(
141
+ ERROR_INVALID_REQUEST,
142
+ "method #{method.inspect} is not valid after `initialize` has succeeded"
143
+ )
144
+ when :shutdown
145
+ rpc_error(
146
+ ERROR_INVALID_REQUEST_AFTER_SHUTDOWN,
147
+ "method #{method.inspect} is not valid after `shutdown`; only `exit` is accepted"
148
+ )
149
+ when :exited
150
+ rpc_error(ERROR_INVALID_REQUEST, "server has exited")
151
+ end
152
+ end
153
+
154
+ # Per LSP spec § "Server lifecycle / initialize": the server
155
+ # responds with its capabilities. Each later slice extends
156
+ # `advertised_capabilities` with the handler it wires;
157
+ # clients asking for unadvertised methods get `MethodNotFound`.
158
+ def handle_initialize(_params)
159
+ @state = :initialized
160
+ {
161
+ capabilities: advertised_capabilities,
162
+ serverInfo: {
163
+ name: "rigor-lsp",
164
+ version: Rigor::VERSION
165
+ }
166
+ }
167
+ end
168
+
169
+ def advertised_capabilities
170
+ caps = {
171
+ textDocumentSync: {
172
+ openClose: true,
173
+ change: TEXT_DOCUMENT_SYNC_FULL
174
+ }
175
+ }
176
+ caps[:hoverProvider] = true if @hover_provider
177
+ caps[:documentSymbolProvider] = true if @document_symbol_provider
178
+ if @completion_provider
179
+ caps[:completionProvider] = {
180
+ # `.` for method completion; `:` for constant-path
181
+ # completion (slice 6). The server detects which form
182
+ # by looking one character back when `:` triggers.
183
+ triggerCharacters: [".", ":"],
184
+ # v1 eager — full payload returned on first request.
185
+ # Resolve becomes relevant if large enumerations
186
+ # (Object descendants) become noticeable.
187
+ resolveProvider: false
188
+ }
189
+ end
190
+ if @signature_help_provider
191
+ caps[:signatureHelpProvider] = {
192
+ # `(` opens the argument list; `,` advances to the
193
+ # next argument. Editors retrigger on both.
194
+ triggerCharacters: ["(", ","]
195
+ }
196
+ end
197
+ caps[:foldingRangeProvider] = true if @folding_range_provider
198
+ caps[:selectionRangeProvider] = true if @selection_range_provider
199
+ caps
200
+ end
201
+
202
+ # `initialized` is a notification — no response body. Slice 7
203
+ # will hook this to register `workspace/didChangeWatchedFiles`
204
+ # if the client advertised the capability.
205
+ def handle_initialized
206
+ nil
207
+ end
208
+
209
+ def handle_shutdown
210
+ @state = :shutdown
211
+ # Drop any in-flight debounced publishes so they don't
212
+ # fire after the client has stopped listening.
213
+ @publisher&.cancel_pending
214
+ nil
215
+ end
216
+
217
+ def handle_exit
218
+ @exit_code = @state == :shutdown ? 0 : 1
219
+ @state = :exited
220
+ nil
221
+ end
222
+
223
+ # textDocument/didOpen notification. Per LSP spec § the
224
+ # `textDocument` payload carries `uri`, `languageId`,
225
+ # `version`, and the full initial `text`. Triggers a
226
+ # `publishDiagnostics` push when a publisher is wired.
227
+ def handle_did_open(params)
228
+ doc = params.fetch(:textDocument)
229
+ uri = doc.fetch(:uri)
230
+ @buffer_table.open(
231
+ uri: uri,
232
+ bytes: doc.fetch(:text),
233
+ version: doc.fetch(:version)
234
+ )
235
+ @publisher&.publish_for(uri)
236
+ nil
237
+ end
238
+
239
+ # textDocument/didChange under FULL sync. Each `contentChanges`
240
+ # entry carries only `{ text: }`; the LAST entry is the new
241
+ # full document text. Per LSP spec § "FULL sync" the array
242
+ # MUST be exactly one entry in practice — we still take
243
+ # `.last` defensively for clients that pad. Triggers
244
+ # `publishDiagnostics` afterwards.
245
+ def handle_did_change(params)
246
+ doc = params.fetch(:textDocument)
247
+ changes = params.fetch(:contentChanges)
248
+ return nil if changes.empty?
249
+
250
+ uri = doc.fetch(:uri)
251
+ @buffer_table.change(
252
+ uri: uri,
253
+ bytes: changes.last.fetch(:text),
254
+ version: doc.fetch(:version)
255
+ )
256
+ @publisher&.publish_for(uri)
257
+ nil
258
+ end
259
+
260
+ # textDocument/hover REQUEST. Slice 5 returns either a
261
+ # `Hover` payload (markdown contents wrapping type +
262
+ # erased-RBS info) or nil when no expression is at the
263
+ # queried position. Nil maps to `result: null` per LSP
264
+ # spec; clients suppress the popup. Returns
265
+ # `MethodNotFound` when no hover_provider is wired (slice
266
+ # 1-4 behaviour).
267
+ def handle_hover(params)
268
+ return method_not_found("textDocument/hover") unless @hover_provider
269
+
270
+ doc = params.fetch(:textDocument)
271
+ pos = params.fetch(:position)
272
+ @hover_provider.provide(
273
+ uri: doc.fetch(:uri),
274
+ line: pos.fetch(:line),
275
+ character: pos.fetch(:character)
276
+ )
277
+ end
278
+
279
+ # workspace/didChangeWatchedFiles NOTIFICATION. Invalidates
280
+ # the ProjectContext so cached pre-pass / Environment is
281
+ # rebuilt on the next request. Slice 7's floor: any watched
282
+ # file change triggers a full context rebuild. Per-file
283
+ # surgical invalidation (per design doc § "Project context
284
+ # refresh") is a follow-up; this is the LSP-correct floor.
285
+ def handle_did_change_watched_files(_params)
286
+ @project_context&.invalidate!
287
+ nil
288
+ end
289
+
290
+ # workspace/didChangeConfiguration NOTIFICATION. The payload
291
+ # shape is client-specific; v1 ignores the payload and
292
+ # invalidates the context so the next read picks up any
293
+ # external config changes (.rigor.yml / Gemfile.lock / etc).
294
+ def handle_did_change_configuration(_params)
295
+ @project_context&.invalidate!
296
+ nil
297
+ end
298
+
299
+ # textDocument/selectionRange REQUEST. Routes to the
300
+ # selection-range provider when wired; `MethodNotFound`
301
+ # otherwise.
302
+ def handle_selection_range(params)
303
+ return method_not_found("textDocument/selectionRange") unless @selection_range_provider
304
+
305
+ doc = params.fetch(:textDocument)
306
+ positions = params.fetch(:positions)
307
+ @selection_range_provider.provide(doc.fetch(:uri), positions)
308
+ end
309
+
310
+ # textDocument/foldingRange REQUEST. Routes to the
311
+ # folding-range provider when wired; `MethodNotFound`
312
+ # otherwise.
313
+ def handle_folding_range(params)
314
+ return method_not_found("textDocument/foldingRange") unless @folding_range_provider
315
+
316
+ doc = params.fetch(:textDocument)
317
+ @folding_range_provider.provide(doc.fetch(:uri))
318
+ end
319
+
320
+ # textDocument/signatureHelp REQUEST. Routes to the
321
+ # signature-help provider when wired; `MethodNotFound`
322
+ # otherwise.
323
+ def handle_signature_help(params)
324
+ return method_not_found("textDocument/signatureHelp") unless @signature_help_provider
325
+
326
+ doc = params.fetch(:textDocument)
327
+ pos = params.fetch(:position)
328
+ context = params[:context]
329
+ @signature_help_provider.provide(
330
+ uri: doc.fetch(:uri),
331
+ line: pos.fetch(:line),
332
+ character: pos.fetch(:character),
333
+ context: context
334
+ )
335
+ end
336
+
337
+ # textDocument/completion REQUEST. Routes to the completion
338
+ # provider when wired; `MethodNotFound` otherwise.
339
+ def handle_completion(params)
340
+ return method_not_found("textDocument/completion") unless @completion_provider
341
+
342
+ doc = params.fetch(:textDocument)
343
+ pos = params.fetch(:position)
344
+ context = params[:context] || {}
345
+ @completion_provider.provide(
346
+ uri: doc.fetch(:uri),
347
+ line: pos.fetch(:line),
348
+ character: pos.fetch(:character),
349
+ trigger_character: context[:triggerCharacter]
350
+ )
351
+ end
352
+
353
+ # textDocument/documentSymbol REQUEST. Returns the
354
+ # `DocumentSymbol[]` outline for the buffer at the requested
355
+ # URI. Returns `MethodNotFound` when no provider is wired.
356
+ def handle_document_symbol(params)
357
+ return method_not_found("textDocument/documentSymbol") unless @document_symbol_provider
358
+
359
+ doc = params.fetch(:textDocument)
360
+ @document_symbol_provider.provide(doc.fetch(:uri))
361
+ end
362
+
363
+ # textDocument/didClose. Drops the buffer table entry AND
364
+ # publishes an empty diagnostic set so clients clear inline
365
+ # markers — per LSP spec § "publishDiagnostics" the standard
366
+ # way to indicate "no diagnostics remain for this URI".
367
+ def handle_did_close(params)
368
+ doc = params.fetch(:textDocument)
369
+ uri = doc.fetch(:uri)
370
+ @buffer_table.close(uri: uri)
371
+ @publisher&.publish_empty(uri)
372
+ nil
373
+ end
374
+
375
+ def method_not_found(method)
376
+ rpc_error(ERROR_METHOD_NOT_FOUND, "method not found: #{method.inspect}")
377
+ end
378
+
379
+ def rpc_error(code, message)
380
+ { error: { code: code, message: message } }
381
+ end
382
+ end
383
+ end
384
+ end