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.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +76 -79
  3. data/lib/rigor/analysis/baseline.rb +347 -0
  4. data/lib/rigor/analysis/buffer_binding.rb +36 -0
  5. data/lib/rigor/analysis/check_rules.rb +68 -3
  6. data/lib/rigor/analysis/dependency_source_inference/index.rb +14 -1
  7. data/lib/rigor/analysis/dependency_source_inference/return_type_heuristic.rb +105 -0
  8. data/lib/rigor/analysis/dependency_source_inference/walker.rb +32 -12
  9. data/lib/rigor/analysis/project_scan.rb +39 -0
  10. data/lib/rigor/analysis/runner.rb +309 -22
  11. data/lib/rigor/analysis/worker_session.rb +14 -2
  12. data/lib/rigor/builtins/hkt_builtins.rb +342 -0
  13. data/lib/rigor/builtins/static_return_refinements.rb +142 -0
  14. data/lib/rigor/cache/store.rb +33 -3
  15. data/lib/rigor/cli/baseline_command.rb +377 -0
  16. data/lib/rigor/cli/lsp_command.rb +129 -0
  17. data/lib/rigor/cli/type_of_command.rb +44 -5
  18. data/lib/rigor/cli.rb +142 -13
  19. data/lib/rigor/configuration.rb +58 -2
  20. data/lib/rigor/environment/hkt_registry_holder.rb +33 -0
  21. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  22. data/lib/rigor/environment/rbs_loader.rb +67 -2
  23. data/lib/rigor/environment/reporters.rb +40 -0
  24. data/lib/rigor/environment.rb +119 -9
  25. data/lib/rigor/flow_contribution/fact.rb +20 -10
  26. data/lib/rigor/inference/acceptance.rb +48 -3
  27. data/lib/rigor/inference/expression_typer.rb +64 -2
  28. data/lib/rigor/inference/hkt_body.rb +171 -0
  29. data/lib/rigor/inference/hkt_body_parser.rb +363 -0
  30. data/lib/rigor/inference/hkt_reducer.rb +256 -0
  31. data/lib/rigor/inference/hkt_registry.rb +223 -0
  32. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +125 -30
  33. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +32 -11
  34. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +87 -0
  35. data/lib/rigor/inference/method_dispatcher.rb +174 -6
  36. data/lib/rigor/inference/narrowing.rb +103 -1
  37. data/lib/rigor/inference/project_patched_methods.rb +70 -0
  38. data/lib/rigor/inference/project_patched_scanner.rb +210 -0
  39. data/lib/rigor/inference/scope_indexer.rb +209 -19
  40. data/lib/rigor/inference/statement_evaluator.rb +172 -11
  41. data/lib/rigor/inference/synthetic_method_scanner.rb +94 -16
  42. data/lib/rigor/language_server/buffer_table.rb +63 -0
  43. data/lib/rigor/language_server/completion_provider.rb +438 -0
  44. data/lib/rigor/language_server/debouncer.rb +86 -0
  45. data/lib/rigor/language_server/diagnostic_publisher.rb +167 -0
  46. data/lib/rigor/language_server/document_symbol_provider.rb +142 -0
  47. data/lib/rigor/language_server/folding_range_provider.rb +75 -0
  48. data/lib/rigor/language_server/hover_provider.rb +74 -0
  49. data/lib/rigor/language_server/hover_renderer.rb +312 -0
  50. data/lib/rigor/language_server/loop.rb +71 -0
  51. data/lib/rigor/language_server/project_context.rb +145 -0
  52. data/lib/rigor/language_server/selection_range_provider.rb +93 -0
  53. data/lib/rigor/language_server/server.rb +384 -0
  54. data/lib/rigor/language_server/signature_help_provider.rb +249 -0
  55. data/lib/rigor/language_server/synchronized_writer.rb +28 -0
  56. data/lib/rigor/language_server/uri.rb +40 -0
  57. data/lib/rigor/language_server.rb +29 -0
  58. data/lib/rigor/plugin/base.rb +63 -0
  59. data/lib/rigor/plugin/macro/heredoc_template.rb +127 -13
  60. data/lib/rigor/plugin/macro/trait_registry.rb +1 -1
  61. data/lib/rigor/plugin/manifest.rb +54 -7
  62. data/lib/rigor/plugin/registry.rb +19 -0
  63. data/lib/rigor/rbs_extended/hkt_directives.rb +326 -0
  64. data/lib/rigor/rbs_extended.rb +82 -2
  65. data/lib/rigor/sig_gen/generator.rb +12 -3
  66. data/lib/rigor/type/app.rb +107 -0
  67. data/lib/rigor/type.rb +1 -0
  68. data/lib/rigor/version.rb +1 -1
  69. data/sig/rigor/environment.rbs +10 -4
  70. data/sig/rigor/inference.rbs +2 -0
  71. data/sig/rigor.rbs +4 -1
  72. 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