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,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "uri"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
module LanguageServer
|
|
9
|
+
# Answers `textDocument/documentSymbol` requests by walking the
|
|
10
|
+
# Prism AST and emitting one LSP `DocumentSymbol` per
|
|
11
|
+
# `ClassNode` / `ModuleNode` / `DefNode`. Nested classes /
|
|
12
|
+
# modules / methods nest in the `children` array so the editor's
|
|
13
|
+
# outline tree mirrors the source structure.
|
|
14
|
+
#
|
|
15
|
+
# SymbolKind mapping (LSP § "SymbolKind"):
|
|
16
|
+
# - Class (5) — `class Foo`
|
|
17
|
+
# - Module (2) — `module Foo`
|
|
18
|
+
# - Method (6) — `def m` inside a class / module
|
|
19
|
+
# - Function (12) — `def m` at top-level (no enclosing class)
|
|
20
|
+
class DocumentSymbolProvider
|
|
21
|
+
KIND_MODULE = 2
|
|
22
|
+
KIND_CLASS = 5
|
|
23
|
+
KIND_METHOD = 6
|
|
24
|
+
KIND_FUNCTION = 12
|
|
25
|
+
|
|
26
|
+
def initialize(buffer_table:, project_context:)
|
|
27
|
+
@buffer_table = buffer_table
|
|
28
|
+
@project_context = project_context
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [Array<Hash>, nil] LSP `DocumentSymbol[]` for the
|
|
32
|
+
# buffer at `uri`. Returns nil when the URI isn't open or
|
|
33
|
+
# doesn't parse cleanly enough to surface symbols — LSP
|
|
34
|
+
# clients fall back to no-outline in that case.
|
|
35
|
+
def provide(uri)
|
|
36
|
+
path = Uri.to_path(uri)
|
|
37
|
+
return nil if path.nil?
|
|
38
|
+
|
|
39
|
+
entry = @buffer_table[uri]
|
|
40
|
+
return nil if entry.nil?
|
|
41
|
+
|
|
42
|
+
parse_result = Prism.parse(entry.bytes, filepath: path,
|
|
43
|
+
version: @project_context.configuration.target_ruby)
|
|
44
|
+
# Tolerate partial parse errors: walk what Prism gave us
|
|
45
|
+
# anyway. Editors prefer a stale outline over no outline.
|
|
46
|
+
walk_top_level(parse_result.value)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def walk_top_level(root)
|
|
52
|
+
symbols = []
|
|
53
|
+
each_decl(root, in_namespace: false) { |s| symbols << s }
|
|
54
|
+
symbols
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def each_decl(node, in_namespace:, &block)
|
|
58
|
+
return unless node.is_a?(Prism::Node)
|
|
59
|
+
|
|
60
|
+
case node
|
|
61
|
+
when Prism::ClassNode
|
|
62
|
+
children = []
|
|
63
|
+
each_decl(node.body, in_namespace: true) { |child| children << child } if node.body
|
|
64
|
+
block.call(class_symbol(node, children))
|
|
65
|
+
when Prism::ModuleNode
|
|
66
|
+
children = []
|
|
67
|
+
each_decl(node.body, in_namespace: true) { |child| children << child } if node.body
|
|
68
|
+
block.call(module_symbol(node, children))
|
|
69
|
+
when Prism::DefNode
|
|
70
|
+
block.call(def_symbol(node, in_namespace: in_namespace))
|
|
71
|
+
else
|
|
72
|
+
node.compact_child_nodes.each do |child|
|
|
73
|
+
each_decl(child, in_namespace: in_namespace, &block)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def class_symbol(node, children)
|
|
79
|
+
{
|
|
80
|
+
name: qualified_name_of(node.constant_path),
|
|
81
|
+
kind: KIND_CLASS,
|
|
82
|
+
range: range_from(node.location),
|
|
83
|
+
selectionRange: range_from(node.constant_path.location),
|
|
84
|
+
children: children
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def module_symbol(node, children)
|
|
89
|
+
{
|
|
90
|
+
name: qualified_name_of(node.constant_path),
|
|
91
|
+
kind: KIND_MODULE,
|
|
92
|
+
range: range_from(node.location),
|
|
93
|
+
selectionRange: range_from(node.constant_path.location),
|
|
94
|
+
children: children
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def def_symbol(node, in_namespace:)
|
|
99
|
+
name = node.name.to_s
|
|
100
|
+
# `def self.foo` (singleton-class) → singleton-method shape.
|
|
101
|
+
# We surface the same as instance methods in v1 (LSP kind
|
|
102
|
+
# has no distinct "ClassMethod" code); the textual `self.`
|
|
103
|
+
# prefix preserves the distinction visually.
|
|
104
|
+
display_name = node.receiver.is_a?(Prism::SelfNode) ? "self.#{name}" : name
|
|
105
|
+
{
|
|
106
|
+
name: display_name,
|
|
107
|
+
kind: in_namespace ? KIND_METHOD : KIND_FUNCTION,
|
|
108
|
+
range: range_from(node.location),
|
|
109
|
+
selectionRange: range_from(node.name_loc),
|
|
110
|
+
children: []
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Renders a `ConstantReadNode` / `ConstantPathNode` as its
|
|
115
|
+
# fully-qualified name string (e.g. `Foo::Bar::Baz`). Returns
|
|
116
|
+
# the slot-name source when the node shape is unrecognised so
|
|
117
|
+
# the outline still has something to display.
|
|
118
|
+
def qualified_name_of(node)
|
|
119
|
+
case node
|
|
120
|
+
when Prism::ConstantReadNode
|
|
121
|
+
node.name.to_s
|
|
122
|
+
when Prism::ConstantPathNode
|
|
123
|
+
parent = node.parent.nil? ? nil : qualified_name_of(node.parent)
|
|
124
|
+
parent.nil? ? node.name.to_s : "#{parent}::#{node.name}"
|
|
125
|
+
else
|
|
126
|
+
node.respond_to?(:slice) ? node.slice : "<unknown>"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# LSP `Range` is 0-based start + end with `character` in
|
|
131
|
+
# UTF-16 code units. Slice 6 emits byte columns (correct for
|
|
132
|
+
# ASCII source); UTF-16 conversion stays queued per design
|
|
133
|
+
# doc § "Open questions".
|
|
134
|
+
def range_from(location)
|
|
135
|
+
{
|
|
136
|
+
start: { line: location.start_line - 1, character: location.start_column },
|
|
137
|
+
end: { line: location.end_line - 1, character: location.end_column }
|
|
138
|
+
}
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "uri"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
module LanguageServer
|
|
9
|
+
# Answers `textDocument/foldingRange` requests. Walks the Prism
|
|
10
|
+
# AST and emits one `FoldingRange` per foldable construct:
|
|
11
|
+
# `class` / `module` / `def` / `singleton class << self` /
|
|
12
|
+
# block (`do…end` or `{…}`). Skips single-line constructs
|
|
13
|
+
# (start_line == end_line) since there's nothing to fold.
|
|
14
|
+
#
|
|
15
|
+
# Ranges are LSP 0-based. `startLine` is the line containing
|
|
16
|
+
# the opening keyword (`class`, `def`); `endLine` is the last
|
|
17
|
+
# line OF the body — one line before the `end` keyword — so
|
|
18
|
+
# collapsed view shows the opener intact and hides the body
|
|
19
|
+
# only.
|
|
20
|
+
class FoldingRangeProvider
|
|
21
|
+
def initialize(buffer_table:, project_context:)
|
|
22
|
+
@buffer_table = buffer_table
|
|
23
|
+
@project_context = project_context
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [Array<Hash>, nil] LSP `FoldingRange[]` for the
|
|
27
|
+
# buffer, or nil when the URI isn't open / parseable.
|
|
28
|
+
def provide(uri)
|
|
29
|
+
path = Uri.to_path(uri)
|
|
30
|
+
return nil if path.nil?
|
|
31
|
+
|
|
32
|
+
entry = @buffer_table[uri]
|
|
33
|
+
return nil if entry.nil?
|
|
34
|
+
|
|
35
|
+
parse_result = Prism.parse(entry.bytes, filepath: path,
|
|
36
|
+
version: @project_context.configuration.target_ruby)
|
|
37
|
+
# Tolerate partial parse errors — fold whatever AST Prism
|
|
38
|
+
# produced. Editors prefer a stale outline / fold map
|
|
39
|
+
# over none.
|
|
40
|
+
ranges = []
|
|
41
|
+
walk(parse_result.value, ranges)
|
|
42
|
+
ranges
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def walk(node, ranges)
|
|
48
|
+
return unless node.is_a?(Prism::Node)
|
|
49
|
+
|
|
50
|
+
case node
|
|
51
|
+
when Prism::ClassNode, Prism::ModuleNode,
|
|
52
|
+
Prism::SingletonClassNode, Prism::DefNode,
|
|
53
|
+
Prism::BlockNode
|
|
54
|
+
add_range(node, ranges)
|
|
55
|
+
end
|
|
56
|
+
node.compact_child_nodes.each { |child| walk(child, ranges) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def add_range(node, ranges)
|
|
60
|
+
loc = node.location
|
|
61
|
+
start_line = loc.start_line - 1
|
|
62
|
+
# `end_line` includes the line of the `end` keyword.
|
|
63
|
+
# Folding should hide the body and leave both the opener
|
|
64
|
+
# AND the `end` keyword visible — so endLine is one line
|
|
65
|
+
# before `end`. When the body is a single line, that
|
|
66
|
+
# makes start == end (one-line fold), which most editors
|
|
67
|
+
# skip; we filter those out.
|
|
68
|
+
end_line = loc.end_line - 2
|
|
69
|
+
return if end_line <= start_line
|
|
70
|
+
|
|
71
|
+
ranges << { startLine: start_line, endLine: end_line }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "uri"
|
|
6
|
+
require_relative "hover_renderer"
|
|
7
|
+
require_relative "../environment"
|
|
8
|
+
require_relative "../scope"
|
|
9
|
+
require_relative "../source/node_locator"
|
|
10
|
+
require_relative "../inference/scope_indexer"
|
|
11
|
+
|
|
12
|
+
module Rigor
|
|
13
|
+
module LanguageServer
|
|
14
|
+
# Answers `textDocument/hover` requests by running the same
|
|
15
|
+
# NodeLocator + ScopeIndexer + `Scope#type_of` chain that
|
|
16
|
+
# `rigor type-of` already drives. The LSP wraps the result in a
|
|
17
|
+
# `Hover` payload with markdown contents.
|
|
18
|
+
#
|
|
19
|
+
# Per LSP spec § "Position":
|
|
20
|
+
# - `line` and `character` are 0-based.
|
|
21
|
+
# - `character` counts UTF-16 code units; v1 emits byte counts
|
|
22
|
+
# for ASCII source (UTF-16 conversion is queued, see design
|
|
23
|
+
# doc § "Open questions").
|
|
24
|
+
class HoverProvider
|
|
25
|
+
def initialize(buffer_table:, project_context:, renderer: HoverRenderer.new)
|
|
26
|
+
@buffer_table = buffer_table
|
|
27
|
+
@project_context = project_context
|
|
28
|
+
@renderer = renderer
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [Hash, nil] an LSP `Hover` payload or nil when no
|
|
32
|
+
# expression sits at the queried position. Returning nil
|
|
33
|
+
# maps to `result: null` per the LSP spec — clients
|
|
34
|
+
# suppress the hover popup in that case.
|
|
35
|
+
def provide(uri:, line:, character:)
|
|
36
|
+
path = Uri.to_path(uri)
|
|
37
|
+
return nil if path.nil?
|
|
38
|
+
|
|
39
|
+
entry = @buffer_table[uri]
|
|
40
|
+
return nil if entry.nil?
|
|
41
|
+
|
|
42
|
+
parse_result = Prism.parse(entry.bytes, filepath: path, version: @project_context.configuration.target_ruby)
|
|
43
|
+
return nil unless parse_result.errors.empty?
|
|
44
|
+
|
|
45
|
+
# Rigor's NodeLocator uses 1-based line / column; LSP uses
|
|
46
|
+
# 0-based. Translate at the boundary.
|
|
47
|
+
node = locate_node(source: entry.bytes, root: parse_result.value, line: line + 1, character: character + 1)
|
|
48
|
+
return nil if node.nil?
|
|
49
|
+
|
|
50
|
+
scope = base_scope(path)
|
|
51
|
+
index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope)
|
|
52
|
+
node_scope = index[node]
|
|
53
|
+
type = node_scope.type_of(node)
|
|
54
|
+
|
|
55
|
+
@renderer.render(node: node, type: type, node_scope_lookup: index)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def locate_node(source:, root:, line:, character:)
|
|
61
|
+
Source::NodeLocator.at_position(source: source, root: root, line: line, column: character)
|
|
62
|
+
rescue Source::NodeLocator::OutOfRangeError
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def base_scope(_path)
|
|
67
|
+
# Slice 7: pull the Environment from the cached
|
|
68
|
+
# ProjectContext so hovers don't pay the RBS-load tax on
|
|
69
|
+
# every cursor stop.
|
|
70
|
+
Scope.empty(environment: @project_context.environment)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../reflection"
|
|
4
|
+
require_relative "../type/nominal"
|
|
5
|
+
require_relative "../type/singleton"
|
|
6
|
+
require_relative "../type/constant"
|
|
7
|
+
require_relative "../type/refined"
|
|
8
|
+
require_relative "../type/difference"
|
|
9
|
+
|
|
10
|
+
module Rigor
|
|
11
|
+
module LanguageServer
|
|
12
|
+
# Builds the LSP `Hover.contents` markdown body. Dispatches on
|
|
13
|
+
# the hovered Prism node class so each shape (method call,
|
|
14
|
+
# constant, local, literal, …) gets the most relevant
|
|
15
|
+
# type-aware presentation.
|
|
16
|
+
#
|
|
17
|
+
# Slice A1 (this commit) ships:
|
|
18
|
+
# - default body bit-for-bit matching the LSP v1 slice 5
|
|
19
|
+
# output (`type:` / `erased:` / `node:`),
|
|
20
|
+
# - `Prism::CallNode` specialisation surfacing the receiver
|
|
21
|
+
# type + RBS-erased method signature.
|
|
22
|
+
#
|
|
23
|
+
# Slices A2-A4 extend the dispatch with constant /
|
|
24
|
+
# local / ivar / literal renderers per
|
|
25
|
+
# `docs/design/20260517-lsp-hover-completion.md`.
|
|
26
|
+
class HoverRenderer
|
|
27
|
+
# @param node_scope_lookup [#[]] node-to-scope table built
|
|
28
|
+
# by `ScopeIndexer.index`. The renderer indexes into it to
|
|
29
|
+
# retrieve the receiver's narrow scope when specialising on
|
|
30
|
+
# `CallNode`. The lookup never returns nil (the indexer's
|
|
31
|
+
# Hash carries `default_scope` as its default value), so
|
|
32
|
+
# the renderer trusts the lookup result.
|
|
33
|
+
def render(node:, type:, node_scope_lookup:)
|
|
34
|
+
body = render_body(node, type, node_scope_lookup)
|
|
35
|
+
result = { contents: { kind: "markdown", value: body } }
|
|
36
|
+
result[:range] = lsp_range_for(node) if node.respond_to?(:location) && node.location
|
|
37
|
+
result
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# Converts a Prism `Location` to an LSP `Range` (0-based
|
|
43
|
+
# line, 0-based UTF-16-character column). UTF-16 conversion
|
|
44
|
+
# is queued — slice E1 emits byte columns which match for
|
|
45
|
+
# ASCII source; non-ASCII falls back gracefully because
|
|
46
|
+
# clients clamp out-of-range columns to the end of line.
|
|
47
|
+
def lsp_range_for(node)
|
|
48
|
+
loc = node.location
|
|
49
|
+
{
|
|
50
|
+
start: { line: loc.start_line - 1, character: loc.start_column },
|
|
51
|
+
end: { line: loc.end_line - 1, character: loc.end_column }
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def render_body(node, type, node_scope_lookup)
|
|
56
|
+
case node
|
|
57
|
+
when Prism::CallNode
|
|
58
|
+
render_call(node, type, node_scope_lookup) || render_default(node, type)
|
|
59
|
+
when Prism::ConstantReadNode, Prism::ConstantPathNode
|
|
60
|
+
render_constant(node, type, node_scope_lookup) || render_default(node, type)
|
|
61
|
+
when Prism::LocalVariableReadNode, Prism::LocalVariableWriteNode,
|
|
62
|
+
Prism::LocalVariableTargetNode
|
|
63
|
+
render_local(node, type)
|
|
64
|
+
when Prism::InstanceVariableReadNode, Prism::InstanceVariableWriteNode,
|
|
65
|
+
Prism::InstanceVariableTargetNode
|
|
66
|
+
render_ivar(node, type, node_scope_lookup)
|
|
67
|
+
when Prism::IntegerNode, Prism::FloatNode, Prism::RationalNode,
|
|
68
|
+
Prism::ImaginaryNode, Prism::StringNode, Prism::SymbolNode,
|
|
69
|
+
Prism::RegularExpressionNode, Prism::TrueNode, Prism::FalseNode,
|
|
70
|
+
Prism::NilNode, Prism::ArrayNode, Prism::HashNode
|
|
71
|
+
render_literal(node, type)
|
|
72
|
+
else
|
|
73
|
+
render_default(node, type)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Renders a `CallNode` hover when the receiver's type can be
|
|
78
|
+
# mapped to a known RBS class and the method is declared
|
|
79
|
+
# there. Returns nil for shapes the slice-A1 floor doesn't
|
|
80
|
+
# handle (implicit `self`, Union / Refined / Shape receivers
|
|
81
|
+
# — those land in slices A2-A4 and slice 7); the caller falls
|
|
82
|
+
# back to the default body.
|
|
83
|
+
def render_call(call_node, return_type, node_scope_lookup)
|
|
84
|
+
receiver_node = call_node.receiver
|
|
85
|
+
return nil if receiver_node.nil?
|
|
86
|
+
|
|
87
|
+
receiver_scope = node_scope_lookup[receiver_node]
|
|
88
|
+
return nil if receiver_scope.nil?
|
|
89
|
+
|
|
90
|
+
receiver_type = receiver_scope.type_of(receiver_node)
|
|
91
|
+
definition, class_name, kind = lookup_method(receiver_type, call_node.name, receiver_scope)
|
|
92
|
+
return nil if definition.nil?
|
|
93
|
+
|
|
94
|
+
build_call_body(
|
|
95
|
+
class_name: class_name,
|
|
96
|
+
kind: kind,
|
|
97
|
+
method_name: call_node.name,
|
|
98
|
+
definition: definition,
|
|
99
|
+
return_type: return_type
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @return [[RBS::Definition::Method, String, Symbol], nil]
|
|
104
|
+
# the resolved method definition, the receiver class name,
|
|
105
|
+
# and the dispatch kind (`:instance` or `:singleton`); nil
|
|
106
|
+
# when the receiver shape isn't yet supported or the
|
|
107
|
+
# method doesn't resolve through the RBS env.
|
|
108
|
+
def lookup_method(receiver_type, method_name, scope)
|
|
109
|
+
case receiver_type
|
|
110
|
+
when Type::Singleton
|
|
111
|
+
definition = Reflection.singleton_method_definition(
|
|
112
|
+
receiver_type.class_name, method_name, scope: scope
|
|
113
|
+
)
|
|
114
|
+
[definition, receiver_type.class_name, :singleton]
|
|
115
|
+
else
|
|
116
|
+
class_name = nominal_class_name(receiver_type)
|
|
117
|
+
return [nil, nil, nil] if class_name.nil?
|
|
118
|
+
|
|
119
|
+
definition = Reflection.instance_method_definition(
|
|
120
|
+
class_name, method_name, scope: scope
|
|
121
|
+
)
|
|
122
|
+
[definition, class_name, :instance]
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Maps a receiver carrier to the underlying RBS class name
|
|
127
|
+
# for method lookup. v1 handles the two carriers that produce
|
|
128
|
+
# well-defined ".methods to enumerate" semantics: `Nominal[C]`
|
|
129
|
+
# (the canonical case) and `Constant<v>` (literal scalars,
|
|
130
|
+
# where the value's runtime class is the receiver). Other
|
|
131
|
+
# carriers fall through to the default hover; slice 7 of the
|
|
132
|
+
# design adds Union / Refined / Shape handling.
|
|
133
|
+
def nominal_class_name(type)
|
|
134
|
+
case type
|
|
135
|
+
when Type::Nominal then type.class_name
|
|
136
|
+
when Type::Constant then constant_to_class_name(type.value)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# `Type::Constant<value>`'s `value` is the literal Ruby
|
|
141
|
+
# object; map it to the RBS-canonical class name through
|
|
142
|
+
# `Object#class`. The cross-runtime mapping mirrors how the
|
|
143
|
+
# dispatcher already widens a literal to its nominal class
|
|
144
|
+
# at the call site — keep these in sync if a new literal
|
|
145
|
+
# carrier lands.
|
|
146
|
+
def constant_to_class_name(value)
|
|
147
|
+
value.class.name
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def build_call_body(class_name:, kind:, method_name:, definition:, return_type:)
|
|
151
|
+
sep = kind == :singleton ? "." : "#"
|
|
152
|
+
body = +"```ruby\n"
|
|
153
|
+
body << "# Receiver\n"
|
|
154
|
+
body << "#{class_name}\n\n"
|
|
155
|
+
body << "# Method\n"
|
|
156
|
+
body << "#{class_name}#{sep}#{method_name}: #{first_method_type(definition)}\n\n"
|
|
157
|
+
body << "# Return\n"
|
|
158
|
+
body << "#{return_type.describe}\n"
|
|
159
|
+
body << "```"
|
|
160
|
+
if (doc = rbs_documentation(definition))
|
|
161
|
+
# Close the code fence first so the comment text renders
|
|
162
|
+
# as prose, then a fresh fenced block isn't necessary —
|
|
163
|
+
# plain markdown below the code reads better in clients.
|
|
164
|
+
body << "\n\n---\n\n#{doc}"
|
|
165
|
+
end
|
|
166
|
+
body
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Returns the concatenated text of every RBS comment attached
|
|
170
|
+
# to the method definition, or nil when no comments exist.
|
|
171
|
+
# `RBS::Definition::Method#comments` is an Array<AST::Comment>
|
|
172
|
+
# with each entry's `.string` carrying the raw text (newline-
|
|
173
|
+
# terminated `# foo` lines). Multiple comments are joined
|
|
174
|
+
# with a blank line so each upstream `# Foo bar` paragraph
|
|
175
|
+
# is preserved.
|
|
176
|
+
def rbs_documentation(definition)
|
|
177
|
+
comments = definition.respond_to?(:comments) ? definition.comments : nil
|
|
178
|
+
return nil if comments.nil? || comments.empty?
|
|
179
|
+
|
|
180
|
+
text = comments.map(&:string).join("\n\n").strip
|
|
181
|
+
text.empty? ? nil : text
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# v1 surfaces the FIRST overload only. Multi-overload
|
|
185
|
+
# presentation is queued (design doc § "Out of scope for v2"
|
|
186
|
+
# — multi-overload signature display).
|
|
187
|
+
def first_method_type(definition)
|
|
188
|
+
method_type = definition.method_types.first
|
|
189
|
+
return "(unknown)" if method_type.nil?
|
|
190
|
+
|
|
191
|
+
method_type.to_s
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Specialises `ConstantReadNode` (`Foo`) and `ConstantPathNode`
|
|
195
|
+
# (`Foo::Bar`) when the inferred type is a `Type::Singleton`
|
|
196
|
+
# — i.e., the constant refers to a class / module. Returns nil
|
|
197
|
+
# for constants pointing at values (`FOO = 42`); those fall
|
|
198
|
+
# through to the literal-polish slice (A4).
|
|
199
|
+
def render_constant(node, type, node_scope_lookup)
|
|
200
|
+
return nil unless type.is_a?(Type::Singleton)
|
|
201
|
+
|
|
202
|
+
fqn = type.class_name
|
|
203
|
+
scope = node_scope_lookup[node]
|
|
204
|
+
location = defined_in(fqn, scope)
|
|
205
|
+
|
|
206
|
+
body = +"```ruby\n"
|
|
207
|
+
body << "# Constant\n#{fqn}\n\n"
|
|
208
|
+
body << "# Type\nsingleton(#{fqn})\n"
|
|
209
|
+
body << "\n# Defined in\n#{location}\n" if location
|
|
210
|
+
body << "```"
|
|
211
|
+
body
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Resolves the source-file location for a class FQN by reading
|
|
215
|
+
# the RBS loader's `class_decl_paths` table. Returns nil when
|
|
216
|
+
# the table doesn't carry attribution (cache-hit paths replace
|
|
217
|
+
# it with a sentinel, see `RunStats.attribution_available?`).
|
|
218
|
+
def defined_in(fqn, scope)
|
|
219
|
+
loader = scope&.environment&.rbs_loader
|
|
220
|
+
return nil if loader.nil?
|
|
221
|
+
|
|
222
|
+
path = loader.class_decl_paths[fqn]
|
|
223
|
+
return nil if path.nil? || path.empty?
|
|
224
|
+
|
|
225
|
+
path
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Specialises local-variable reads / writes / target nodes.
|
|
229
|
+
# Surfaces the variable name + narrowed type. "Bound at"
|
|
230
|
+
# source-line attribution is queued (Scope#locals tracks
|
|
231
|
+
# {name => Type} but not the binding location); when the
|
|
232
|
+
# ScopeIndexer grows a side-table for it, slice A3 follow-up
|
|
233
|
+
# can fill the row in.
|
|
234
|
+
def render_local(node, type)
|
|
235
|
+
body = +"```ruby\n"
|
|
236
|
+
body << "# Local\n#{node.name}\n\n"
|
|
237
|
+
body << "# Type\n#{type.describe}\n"
|
|
238
|
+
body << "```"
|
|
239
|
+
body
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Specialises instance-variable reads / writes / targets.
|
|
243
|
+
# Surfaces the ivar name + narrowed type + the enclosing
|
|
244
|
+
# class context derived from the scope's `self_type`. When
|
|
245
|
+
# the self_type isn't a Nominal (e.g., top-level main) the
|
|
246
|
+
# enclosing-class row is omitted.
|
|
247
|
+
def render_ivar(node, type, node_scope_lookup)
|
|
248
|
+
scope = node_scope_lookup[node]
|
|
249
|
+
body = +"```ruby\n"
|
|
250
|
+
body << "# Ivar\n#{node.name}\n\n"
|
|
251
|
+
body << "# Type\n#{type.describe}\n"
|
|
252
|
+
if scope && (enclosing = enclosing_class_for(scope))
|
|
253
|
+
body << "\n# In class\n#{enclosing}\n"
|
|
254
|
+
end
|
|
255
|
+
body << "```"
|
|
256
|
+
body
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def enclosing_class_for(scope)
|
|
260
|
+
self_type = scope.self_type
|
|
261
|
+
# Both `Nominal[C]` and `Singleton[C]` carry `class_name`;
|
|
262
|
+
# we want the class label either way. Combined branch
|
|
263
|
+
# keeps the slice-A3 contract simple.
|
|
264
|
+
self_type.class_name if self_type.is_a?(Type::Nominal) || self_type.is_a?(Type::Singleton)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Specialises literal-bearing nodes (Integer / Float / String /
|
|
268
|
+
# Symbol / Regex / true / false / nil / Array / Hash). Drops
|
|
269
|
+
# the slice-A1 `node:` debug row in favour of a cleaner
|
|
270
|
+
# `# Type` + `# Erased` framing, and surfaces the refinement
|
|
271
|
+
# / difference name when one is present. For Array / Hash
|
|
272
|
+
# the shape carriers (`Tuple<...>` / `HashShape<...>`) already
|
|
273
|
+
# describe element types, so the framing is identical to
|
|
274
|
+
# primitive literals.
|
|
275
|
+
def render_literal(_node, type)
|
|
276
|
+
body = +"```ruby\n"
|
|
277
|
+
body << "# Type\n#{type.describe}\n"
|
|
278
|
+
body << "\n# Erased\n#{type.erase_to_rbs}\n"
|
|
279
|
+
if (name = refinement_name_for(type))
|
|
280
|
+
body << "\n# Refinement\n#{name}\n"
|
|
281
|
+
end
|
|
282
|
+
body << "```"
|
|
283
|
+
body
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Surfaces the canonical kebab-case refinement name when the
|
|
287
|
+
# type is a `Refined` or `Difference` carrier with a
|
|
288
|
+
# registered canonical_name (e.g. `non-empty-string` /
|
|
289
|
+
# `positive-int`). `canonical_name` is private on both
|
|
290
|
+
# carriers; the LSP layer is a trusted internal consumer
|
|
291
|
+
# and `send` is the documented escape hatch for surfacing
|
|
292
|
+
# display-level metadata. Returns nil for unrefined carriers
|
|
293
|
+
# and for refinements that don't have a canonical name (those
|
|
294
|
+
# are presented through the predicate-id operator form by
|
|
295
|
+
# `describe`).
|
|
296
|
+
def refinement_name_for(type)
|
|
297
|
+
return nil unless type.is_a?(Type::Refined) || type.is_a?(Type::Difference)
|
|
298
|
+
|
|
299
|
+
type.send(:canonical_name)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def render_default(node, type)
|
|
303
|
+
body = +"```ruby\n"
|
|
304
|
+
body << "type: #{type.describe}\n"
|
|
305
|
+
body << "erased: #{type.erase_to_rbs}\n"
|
|
306
|
+
body << "node: #{node.class}\n"
|
|
307
|
+
body << "```"
|
|
308
|
+
body
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "language_server-protocol"
|
|
5
|
+
|
|
6
|
+
module Rigor
|
|
7
|
+
module LanguageServer
|
|
8
|
+
# JSON-RPC dispatch loop. Drains messages from `reader`, routes
|
|
9
|
+
# each to `server.dispatch`, and writes responses back through
|
|
10
|
+
# `writer`. Stops when either the reader hits EOF (client closed
|
|
11
|
+
# its end of the pipe) or the server transitions to `:exited`.
|
|
12
|
+
#
|
|
13
|
+
# The Loop knows the request / notification distinction from the
|
|
14
|
+
# presence of the `id` field on the inbound JSON-RPC envelope:
|
|
15
|
+
#
|
|
16
|
+
# - Request (`id` present) → ALWAYS gets a response (success or
|
|
17
|
+
# error). `Server#dispatch` returning nil for a request maps
|
|
18
|
+
# to `result: null` per the LSP shutdown contract.
|
|
19
|
+
# - Notification (`id` absent) → NEVER gets a response. The
|
|
20
|
+
# dispatcher's return value is discarded.
|
|
21
|
+
#
|
|
22
|
+
# JSON parse errors at the framing boundary surface as an LSP
|
|
23
|
+
# `ParseError` (-32700) response with `id: null` per JSON-RPC
|
|
24
|
+
# spec § 5.1; the loop continues so a corrupt frame doesn't
|
|
25
|
+
# poison the rest of the session.
|
|
26
|
+
class Loop
|
|
27
|
+
def initialize(reader:, writer:, server:)
|
|
28
|
+
@reader = reader
|
|
29
|
+
@writer = writer
|
|
30
|
+
@server = server
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def run
|
|
34
|
+
@reader.read do |request|
|
|
35
|
+
handle(request)
|
|
36
|
+
break if @server.exited?
|
|
37
|
+
end
|
|
38
|
+
rescue JSON::ParserError => e
|
|
39
|
+
@writer.write(id: nil, error: { code: Server::ERROR_PARSE_ERROR, message: e.message })
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def handle(request)
|
|
45
|
+
method = request[:method]
|
|
46
|
+
params = request[:params]
|
|
47
|
+
id = request[:id]
|
|
48
|
+
|
|
49
|
+
result = @server.dispatch(method, params)
|
|
50
|
+
return if id.nil? # notification — no response.
|
|
51
|
+
|
|
52
|
+
write_response(id, result)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Maps the dispatcher's return value to a JSON-RPC response
|
|
56
|
+
# envelope. `Server#dispatch` returns one of three shapes:
|
|
57
|
+
#
|
|
58
|
+
# - `{ error: {...} }` — surfaced as `{ id, error: {...} }`.
|
|
59
|
+
# - any other Hash — surfaced as `{ id, result: hash }`.
|
|
60
|
+
# - `nil` — surfaced as `{ id, result: null }` (the LSP
|
|
61
|
+
# `shutdown` contract).
|
|
62
|
+
def write_response(id, result)
|
|
63
|
+
if result.is_a?(Hash) && result.key?(:error)
|
|
64
|
+
@writer.write(id: id, error: result[:error])
|
|
65
|
+
else
|
|
66
|
+
@writer.write(id: id, result: result)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|