pink_spoon 0.1.0

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.
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "rbi_index"
5
+ require_relative "constant_resolver"
6
+ require_relative "definition_finder"
7
+
8
+ module PinkSpoon
9
+ # JSON-RPC 2.0 server over stdin/stdout.
10
+ # Implements only initialize, textDocument/definition, and textDocument/hover.
11
+ # Every other request gets a null result so clients don't hang.
12
+ class Server
13
+ def initialize
14
+ @rbi_index = nil # built lazily after initialize gives us the project root
15
+ @constant_resolver = nil
16
+ @definition_finder = nil
17
+ end
18
+
19
+ def run
20
+ $stdout.sync = true
21
+ $stderr.sync = true
22
+
23
+ loop do
24
+ header = read_header
25
+ break unless header
26
+
27
+ length = header[/Content-Length:\s*(\d+)/i, 1]&.to_i
28
+ next unless length&.positive?
29
+
30
+ body = $stdin.read(length)
31
+ next unless body
32
+
33
+ request = JSON.parse(body, symbolize_names: true)
34
+ handle(request)
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ # ------------------------------------------------------------------
41
+ # Request routing
42
+ # ------------------------------------------------------------------
43
+
44
+ def handle(req)
45
+ id = req[:id]
46
+ method = req[:method]
47
+
48
+ case method
49
+ when "initialize"
50
+ on_initialize(id, req[:params])
51
+ when "initialized"
52
+ # notification, no response needed
53
+ when "shutdown"
54
+ write_response(id, nil)
55
+ when "exit"
56
+ exit(0)
57
+ when "textDocument/definition"
58
+ write_response(id, on_definition(req[:params]))
59
+ when "textDocument/hover"
60
+ write_response(id, on_hover(req[:params]))
61
+ else
62
+ # Return null for anything we don't handle so the client moves on.
63
+ write_response(id, nil) if id
64
+ end
65
+ rescue => e
66
+ $stderr.puts "[pink-spoon] error handling #{req[:method]}: #{e.class}: #{e.message}"
67
+ $stderr.puts e.backtrace.first(10).join("\n")
68
+ write_error(id, -32_603, e.message) if id
69
+ end
70
+
71
+ # ------------------------------------------------------------------
72
+ # Handlers
73
+ # ------------------------------------------------------------------
74
+
75
+ def on_initialize(id, params)
76
+ root_uri = params&.dig(:rootUri) || params&.dig(:rootPath)
77
+ root_path = root_uri&.delete_prefix("file://") || Dir.pwd
78
+
79
+ @rbi_index = RbiIndex.new(root_path)
80
+ @constant_resolver = ConstantResolver.new(root_path, @rbi_index)
81
+ @definition_finder = DefinitionFinder.new(root_path)
82
+
83
+ write_response(id, {
84
+ capabilities: {
85
+ definitionProvider: true,
86
+ hoverProvider: true,
87
+ },
88
+ serverInfo: { name: "pink-spoon", version: "0.1.0" },
89
+ })
90
+ end
91
+
92
+ def on_definition(params)
93
+ return nil unless @rbi_index
94
+
95
+ file = uri_to_path(params.dig(:textDocument, :uri))
96
+ line = params.dig(:position, :line)
97
+ col = params.dig(:position, :character)
98
+
99
+ resolved = @constant_resolver.resolve_at(file, line, col)
100
+ return nil unless resolved
101
+
102
+ location = @definition_finder.find(resolved[:type], resolved[:method])
103
+ return nil unless location
104
+
105
+ {
106
+ uri: path_to_uri(location[:file]),
107
+ range: {
108
+ start: { line: location[:line] - 1, character: 0 },
109
+ end: { line: location[:line] - 1, character: 0 },
110
+ },
111
+ }
112
+ end
113
+
114
+ def on_hover(params)
115
+ return nil unless @rbi_index
116
+
117
+ file = uri_to_path(params.dig(:textDocument, :uri))
118
+ line = params.dig(:position, :line)
119
+ col = params.dig(:position, :character)
120
+
121
+ resolved = @constant_resolver.resolve_at(file, line, col)
122
+ return nil unless resolved
123
+
124
+ sig = @rbi_index.signature_for(resolved[:type], resolved[:method])
125
+ return nil unless sig
126
+
127
+ {
128
+ contents: {
129
+ kind: "markdown",
130
+ value: "```ruby\n#{sig}\n```",
131
+ },
132
+ }
133
+ end
134
+
135
+ # ------------------------------------------------------------------
136
+ # Protocol helpers
137
+ # ------------------------------------------------------------------
138
+
139
+ def read_header
140
+ lines = []
141
+ loop do
142
+ line = $stdin.gets
143
+ return nil if line.nil?
144
+ break if line.strip.empty?
145
+ lines << line.strip
146
+ end
147
+ lines.empty? ? nil : lines.join("\n")
148
+ end
149
+
150
+ def write_response(id, result)
151
+ return unless id
152
+ body = JSON.generate({ jsonrpc: "2.0", id: id, result: result })
153
+ $stdout.write("Content-Length: #{body.bytesize}\r\n\r\n#{body}")
154
+ end
155
+
156
+ def write_error(id, code, message)
157
+ body = JSON.generate({
158
+ jsonrpc: "2.0",
159
+ id: id,
160
+ error: { code: code, message: message },
161
+ })
162
+ $stdout.write("Content-Length: #{body.bytesize}\r\n\r\n#{body}")
163
+ end
164
+
165
+ def uri_to_path(uri)
166
+ uri&.delete_prefix("file://")
167
+ end
168
+
169
+ def path_to_uri(path)
170
+ "file://#{path}"
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PinkSpoon
4
+ VERSION = "0.1.0"
5
+ end
data/lib/pink_spoon.rb ADDED
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "pink_spoon/rbi_index"
4
+ require_relative "pink_spoon/constant_resolver"
5
+ require_relative "pink_spoon/definition_finder"
6
+ require_relative "pink_spoon/doc_extractor"
7
+ require_relative "pink_spoon/server"
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_lsp/addon"
4
+
5
+ require_relative "../../pink_spoon/version"
6
+ require_relative "../../pink_spoon/rbi_index"
7
+ require_relative "../../pink_spoon/constant_resolver"
8
+ require_relative "../../pink_spoon/doc_extractor"
9
+ require_relative "hover_listener"
10
+ require_relative "definition_listener"
11
+ require_relative "completion_listener"
12
+ require_relative "code_lens_listener"
13
+
14
+ module RubyLsp
15
+ module PinkSpoon
16
+ class Addon < ::RubyLsp::Addon
17
+ def activate(global_state, outgoing_queue)
18
+ root = global_state.workspace_path
19
+
20
+ @rbi_index = ::PinkSpoon::RbiIndex.new(root)
21
+ @constant_resolver = ::PinkSpoon::ConstantResolver.new(root, @rbi_index)
22
+ @doc_extractor = ::PinkSpoon::DocExtractor.new(root)
23
+ end
24
+
25
+ def deactivate; end
26
+
27
+ def name = "Pink Spoon"
28
+ def version = ::PinkSpoon::VERSION
29
+
30
+ # @override
31
+ def create_hover_listener(response_builder, node_context, dispatcher)
32
+ HoverListener.new(response_builder, node_context, dispatcher, @constant_resolver, @rbi_index, @doc_extractor)
33
+ end
34
+
35
+ # @override
36
+ def create_definition_listener(response_builder, uri, node_context, dispatcher)
37
+ DefinitionListener.new(response_builder, uri, node_context, dispatcher, @constant_resolver, @rbi_index, @doc_extractor)
38
+ end
39
+
40
+ # @override
41
+ def create_completion_listener(response_builder, node_context, dispatcher, uri)
42
+ CompletionListener.new(response_builder, node_context, dispatcher, uri, @constant_resolver, @rbi_index)
43
+ end
44
+
45
+ # @override
46
+ def create_code_lens_listener(response_builder, uri, dispatcher)
47
+ CodeLensListener.new(response_builder, uri, dispatcher)
48
+ end
49
+
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLsp
4
+ module PinkSpoon
5
+ # Fired on every textDocument/codeLens request.
6
+ # Adds "Run" / "Run in watch mode" code lenses next to RSpec
7
+ # describe/context/it blocks.
8
+ class CodeLensListener
9
+ RSPEC_GROUP_METHODS = %w[describe context feature scenario].freeze
10
+ RSPEC_EXAMPLE_METHODS = %w[it example specify scenario].freeze
11
+
12
+ def initialize(response_builder, uri, dispatcher)
13
+ @response_builder = response_builder
14
+ @uri = uri
15
+
16
+ dispatcher.register(self, :on_call_node_enter)
17
+ end
18
+
19
+ def on_call_node_enter(node)
20
+ method_name = node.name.to_s
21
+ return unless RSPEC_GROUP_METHODS.include?(method_name) ||
22
+ RSPEC_EXAMPLE_METHODS.include?(method_name)
23
+ return if node.receiver # only bare calls (RSpec DSL)
24
+
25
+ description = extract_description(node)
26
+ line = node.location.start_line - 1 # 0-based
27
+
28
+ range = Interface::Range.new(
29
+ start: Interface::Position.new(line: line, character: 0),
30
+ end: Interface::Position.new(line: line, character: 0),
31
+ )
32
+
33
+ @response_builder << Interface::CodeLens.new(
34
+ range: range,
35
+ command: Interface::Command.new(
36
+ title: "▶ Run",
37
+ command: "rubyLsp.runTest",
38
+ arguments: [{ uri: @uri.to_s, name: description, line: line }],
39
+ ),
40
+ )
41
+ end
42
+
43
+ private
44
+
45
+ def extract_description(node)
46
+ first_arg = node.arguments&.arguments&.first
47
+ case first_arg
48
+ when Prism::StringNode then first_arg.unescaped
49
+ when Prism::ConstantReadNode then first_arg.name.to_s
50
+ when Prism::ConstantPathNode then first_arg.slice
51
+ else
52
+ node.name.to_s
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLsp
4
+ module PinkSpoon
5
+ # Fired on every textDocument/completion request.
6
+ #
7
+ # Handles three completion modes:
8
+ # 1. Receiver-qualified call (GAUGE.) → RBI methods for that type.
9
+ # 2. Bare call / partial identifier → locals in scope + class methods
10
+ # from RBI + defs in file + RSpec DSL.
11
+ class CompletionListener
12
+ RSPEC_COMPLETIONS = [
13
+ { name: "describe", snippet: "describe $1 do\n $0\nend", detail: "RSpec · example group" },
14
+ { name: "context", snippet: "context \"$1\" do\n $0\nend", detail: "RSpec · example group" },
15
+ { name: "it", snippet: "it \"$1\" do\n $0\nend", detail: "RSpec · example" },
16
+ { name: "example", snippet: "example \"$1\" do\n $0\nend", detail: "RSpec · example (alias)" },
17
+ { name: "specify", snippet: "specify \"$1\" do\n $0\nend", detail: "RSpec · example (alias)" },
18
+ { name: "let", snippet: "let(:$1) { $0 }", detail: "RSpec · memoized helper" },
19
+ { name: "let!", snippet: "let!(:$1) { $0 }", detail: "RSpec · eager helper" },
20
+ { name: "subject", snippet: "subject { $0 }", detail: "RSpec · subject" },
21
+ { name: "subject!", snippet: "subject!(:$1) { $0 }", detail: "RSpec · named subject" },
22
+ { name: "before", snippet: "before do\n $0\nend", detail: "RSpec · before hook" },
23
+ { name: "after", snippet: "after do\n $0\nend", detail: "RSpec · after hook" },
24
+ { name: "around", snippet: "around do |example|\n $0\n example.run\nend", detail: "RSpec · around hook" },
25
+ { name: "shared_examples", snippet: "shared_examples \"$1\" do\n $0\nend", detail: "RSpec · shared examples" },
26
+ { name: "shared_context", snippet: "shared_context \"$1\" do\n $0\nend", detail: "RSpec · shared context" },
27
+ { name: "include_context", snippet: "include_context \"$1\"", detail: "RSpec · include shared context" },
28
+ { name: "include_examples", snippet: "include_examples \"$1\"", detail: "RSpec · include shared examples" },
29
+ { name: "pending", snippet: "pending \"$1\"", detail: "RSpec · mark pending" },
30
+ { name: "skip", snippet: "skip \"$1\"", detail: "RSpec · skip example" },
31
+ { name: "aggregate_failures", snippet: "aggregate_failures do\n $0\nend", detail: "RSpec · collect failures" },
32
+ ].freeze
33
+
34
+ COMPLETION_KIND_METHOD = 2
35
+ COMPLETION_KIND_VARIABLE = 6
36
+ INSERT_FORMAT_SNIPPET = 2
37
+ INSERT_FORMAT_PLAINTEXT = 1
38
+
39
+ def initialize(response_builder, node_context, dispatcher, uri, resolver, rbi_index)
40
+ @response_builder = response_builder
41
+ @node_context = node_context
42
+ @resolver = resolver
43
+ @rbi_index = rbi_index
44
+ @uri = uri
45
+
46
+ dispatcher.register(
47
+ self,
48
+ :on_call_node_enter,
49
+ :on_local_variable_read_node_enter,
50
+ )
51
+ end
52
+
53
+ def on_call_node_enter(node)
54
+ return unless node.equal?(@node_context.node)
55
+
56
+ if node.receiver
57
+ offer_method_completions(node)
58
+ else
59
+ offer_bare_completions(node.name.to_s, node)
60
+ end
61
+ end
62
+
63
+ # Partial identifier with no parens (e.g. `con` → `context`, `cre` → `creditor_city`).
64
+ def on_local_variable_read_node_enter(node)
65
+ return unless node.equal?(@node_context.node)
66
+ offer_bare_completions(node.name.to_s, node)
67
+ end
68
+
69
+ private
70
+
71
+ # ----------------------------------------------------------------
72
+ # Receiver-qualified completions: GAUGE.inc → enumerate RBI methods.
73
+ # ----------------------------------------------------------------
74
+ def offer_method_completions(node)
75
+ nesting = @node_context.instance_variable_get(:@nesting_nodes)
76
+ program_node = nesting&.find { |n| n.is_a?(Prism::ProgramNode) }
77
+ return unless program_node
78
+
79
+ type = @resolver.resolve_receiver_type(node.receiver, program_node, nesting)
80
+ return unless type
81
+
82
+ range = loc_to_range(node.message_loc || node.location)
83
+
84
+ @rbi_index.methods_for(type).each do |method_name, sig_or_def|
85
+ @response_builder << Interface::CompletionItem.new(
86
+ label: method_name,
87
+ filter_text: method_name,
88
+ kind: COMPLETION_KIND_METHOD,
89
+ detail: sig_or_def&.then { |s| s.start_with?("sig") ? s : nil },
90
+ text_edit: Interface::TextEdit.new(range: range, new_text: method_name),
91
+ insert_text_format: INSERT_FORMAT_PLAINTEXT,
92
+ )
93
+ end
94
+ end
95
+
96
+ # ----------------------------------------------------------------
97
+ # Bare completions: locals + class methods + file defs + RSpec DSL.
98
+ # ----------------------------------------------------------------
99
+ def offer_bare_completions(partial, node)
100
+ range = loc_to_range(
101
+ node.is_a?(Prism::CallNode) ? (node.message_loc || node.location) : node.location,
102
+ )
103
+
104
+ offer_local_var_completions(partial, range)
105
+ offer_class_method_completions(partial, range)
106
+ offer_file_def_completions(partial, range)
107
+ offer_rspec_completions(partial, range)
108
+ end
109
+
110
+ # Local variables in the current scope from NodeContext.
111
+ def offer_local_var_completions(partial, range)
112
+ @node_context.locals_for_scope.each do |sym|
113
+ name = sym.to_s
114
+ next unless partial.empty? || name.start_with?(partial)
115
+
116
+ @response_builder << Interface::CompletionItem.new(
117
+ label: name,
118
+ filter_text: name,
119
+ kind: COMPLETION_KIND_VARIABLE,
120
+ detail: "local variable",
121
+ text_edit: Interface::TextEdit.new(range: range, new_text: name),
122
+ insert_text_format: INSERT_FORMAT_PLAINTEXT,
123
+ )
124
+ end
125
+ end
126
+
127
+ # Methods of the current class from the RBI index (handles typed mixins/parents).
128
+ def offer_class_method_completions(partial, range)
129
+ type = @node_context.fully_qualified_name
130
+ return unless type && !type.empty?
131
+
132
+ @rbi_index.methods_for(type).each do |method_name, sig_or_def|
133
+ next unless partial.empty? || method_name.start_with?(partial)
134
+
135
+ @response_builder << Interface::CompletionItem.new(
136
+ label: method_name,
137
+ filter_text: method_name,
138
+ kind: COMPLETION_KIND_METHOD,
139
+ detail: sig_or_def&.then { |s| s.start_with?("sig") ? s : nil },
140
+ text_edit: Interface::TextEdit.new(range: range, new_text: method_name),
141
+ insert_text_format: INSERT_FORMAT_PLAINTEXT,
142
+ )
143
+ end
144
+ end
145
+
146
+ # Scans the current file for `def method_name` lines — catches project-defined
147
+ # methods that aren't in any RBI (e.g. `creditor_city_enabled?`).
148
+ def offer_file_def_completions(partial, range)
149
+ file = @uri.to_s.delete_prefix("file://")
150
+ return unless file && File.exist?(file)
151
+
152
+ seen = {}
153
+ File.foreach(file) do |line|
154
+ m = line.match(/\bdef\s+(?:self\.)?(\w[\w!?]*)/)
155
+ next unless m
156
+
157
+ name = m[1]
158
+ next if seen[name]
159
+ next unless partial.empty? || name.start_with?(partial)
160
+
161
+ seen[name] = true
162
+ @response_builder << Interface::CompletionItem.new(
163
+ label: name,
164
+ filter_text: name,
165
+ kind: COMPLETION_KIND_METHOD,
166
+ detail: "def",
167
+ text_edit: Interface::TextEdit.new(range: range, new_text: name),
168
+ insert_text_format: INSERT_FORMAT_PLAINTEXT,
169
+ )
170
+ end
171
+ rescue
172
+ nil
173
+ end
174
+
175
+ # RSpec DSL snippets with tab stops.
176
+ def offer_rspec_completions(partial, range)
177
+ RSPEC_COMPLETIONS.each do |item|
178
+ next unless partial.empty? || item[:name].start_with?(partial)
179
+
180
+ @response_builder << Interface::CompletionItem.new(
181
+ label: item[:name],
182
+ filter_text: item[:name],
183
+ kind: COMPLETION_KIND_METHOD,
184
+ detail: item[:detail],
185
+ text_edit: Interface::TextEdit.new(range: range, new_text: item[:snippet]),
186
+ insert_text_format: INSERT_FORMAT_SNIPPET,
187
+ )
188
+ end
189
+ end
190
+
191
+ # Converts a Prism location (1-based lines) to an LSP Range (0-based).
192
+ def loc_to_range(loc)
193
+ Interface::Range.new(
194
+ start: Interface::Position.new(line: loc.start_line - 1, character: loc.start_column),
195
+ end: Interface::Position.new(line: loc.end_line - 1, character: loc.end_column),
196
+ )
197
+ end
198
+ end
199
+ end
200
+ end