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.
- checksums.yaml +4 -4
- data/README.md +36 -50
- 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/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 +120 -0
- data/lib/rigor/cache/store.rb +33 -3
- data/lib/rigor/cli/lsp_command.rb +129 -0
- data/lib/rigor/cli/type_of_command.rb +44 -5
- data/lib/rigor/cli.rb +74 -12
- data/lib/rigor/configuration.rb +38 -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 +45 -2
- data/lib/rigor/environment/reporters.rb +40 -0
- data/lib/rigor/environment.rb +106 -9
- data/lib/rigor/inference/acceptance.rb +48 -3
- data/lib/rigor/inference/expression_typer.rb +47 -0
- 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/receiver_affinity.rb +87 -0
- data/lib/rigor/inference/method_dispatcher.rb +154 -3
- 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_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 +125 -11
- 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 +8 -4
- data/sig/rigor/inference.rbs +2 -0
- data/sig/rigor.rbs +3 -1
- 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
|