sorbet_view 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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/Rakefile +8 -0
- data/exe/sv +6 -0
- data/lib/sorbet_view/cli/runner.rb +206 -0
- data/lib/sorbet_view/compiler/adapters/erb_adapter.rb +170 -0
- data/lib/sorbet_view/compiler/component_compiler.rb +48 -0
- data/lib/sorbet_view/compiler/heredoc_extractor.rb +112 -0
- data/lib/sorbet_view/compiler/parser_adapter.rb +16 -0
- data/lib/sorbet_view/compiler/ruby_generator.rb +211 -0
- data/lib/sorbet_view/compiler/ruby_segment.rb +13 -0
- data/lib/sorbet_view/compiler/template_compiler.rb +41 -0
- data/lib/sorbet_view/compiler/template_context.rb +170 -0
- data/lib/sorbet_view/configuration.rb +34 -0
- data/lib/sorbet_view/file_system/file_watcher.rb +61 -0
- data/lib/sorbet_view/file_system/output_manager.rb +40 -0
- data/lib/sorbet_view/file_system/project_scanner.rb +34 -0
- data/lib/sorbet_view/lsp/document_store.rb +56 -0
- data/lib/sorbet_view/lsp/position_translator.rb +95 -0
- data/lib/sorbet_view/lsp/server.rb +607 -0
- data/lib/sorbet_view/lsp/sorbet_process.rb +164 -0
- data/lib/sorbet_view/lsp/transport.rb +89 -0
- data/lib/sorbet_view/lsp/uri_mapper.rb +105 -0
- data/lib/sorbet_view/perf.rb +92 -0
- data/lib/sorbet_view/source_map/mapping_entry.rb +12 -0
- data/lib/sorbet_view/source_map/position.rb +23 -0
- data/lib/sorbet_view/source_map/range.rb +35 -0
- data/lib/sorbet_view/source_map/source_map.rb +103 -0
- data/lib/sorbet_view/version.rb +6 -0
- data/lib/sorbet_view.rb +36 -0
- data/lib/tapioca/dsl/compilers/sorbet_view.rb +265 -0
- data/sorbet_view.gemspec +34 -0
- data/vscode/.gitignore +3 -0
- data/vscode/.vscode/launch.json +12 -0
- data/vscode/.vscodeignore +4 -0
- data/vscode/package-lock.json +140 -0
- data/vscode/package.json +61 -0
- data/vscode/src/extension.ts +88 -0
- data/vscode/tsconfig.json +17 -0
- metadata +151 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module SorbetView
|
|
5
|
+
module Lsp
|
|
6
|
+
# Translates positions between template and generated Ruby using SourceMaps
|
|
7
|
+
class PositionTranslator
|
|
8
|
+
extend T::Sig
|
|
9
|
+
|
|
10
|
+
sig { void }
|
|
11
|
+
def initialize
|
|
12
|
+
@source_maps = T.let({}, T::Hash[String, SourceMap::SourceMap])
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
sig { params(template_path: String, source_map: SourceMap::SourceMap).void }
|
|
16
|
+
def register(template_path, source_map)
|
|
17
|
+
@source_maps[template_path] = source_map
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
sig { params(template_path: String).void }
|
|
21
|
+
def unregister(template_path)
|
|
22
|
+
@source_maps.delete(template_path)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
sig { params(template_path: String).returns(T.nilable(SourceMap::SourceMap)) }
|
|
26
|
+
def source_map_for(template_path)
|
|
27
|
+
@source_maps[template_path]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
sig { returns(T::Array[String]) }
|
|
31
|
+
def registered_paths
|
|
32
|
+
@source_maps.keys
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Translate an LSP position from template coordinates to Ruby coordinates
|
|
36
|
+
sig do
|
|
37
|
+
params(
|
|
38
|
+
template_path: String,
|
|
39
|
+
lsp_position: T::Hash[String, T.untyped]
|
|
40
|
+
).returns(T.nilable(T::Hash[String, T.untyped]))
|
|
41
|
+
end
|
|
42
|
+
def template_to_ruby(template_path, lsp_position)
|
|
43
|
+
sm = @source_maps[template_path]
|
|
44
|
+
return nil unless sm
|
|
45
|
+
|
|
46
|
+
pos = SourceMap::Position.new(
|
|
47
|
+
line: lsp_position['line'] || lsp_position[:line] || 0,
|
|
48
|
+
column: lsp_position['character'] || lsp_position[:character] || 0
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
ruby_pos = sm.template_to_ruby(pos)
|
|
52
|
+
return nil unless ruby_pos
|
|
53
|
+
|
|
54
|
+
ruby_pos.to_lsp
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Translate an LSP position from Ruby coordinates to template coordinates
|
|
58
|
+
sig do
|
|
59
|
+
params(
|
|
60
|
+
template_path: String,
|
|
61
|
+
lsp_position: T::Hash[String, T.untyped]
|
|
62
|
+
).returns(T.nilable(T::Hash[String, T.untyped]))
|
|
63
|
+
end
|
|
64
|
+
def ruby_to_template(template_path, lsp_position)
|
|
65
|
+
sm = @source_maps[template_path]
|
|
66
|
+
return nil unless sm
|
|
67
|
+
|
|
68
|
+
pos = SourceMap::Position.new(
|
|
69
|
+
line: lsp_position['line'] || lsp_position[:line] || 0,
|
|
70
|
+
column: lsp_position['character'] || lsp_position[:character] || 0
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
template_pos = sm.ruby_to_template(pos)
|
|
74
|
+
return nil unless template_pos
|
|
75
|
+
|
|
76
|
+
template_pos.to_lsp
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Translate an LSP range from Ruby coordinates to template coordinates
|
|
80
|
+
sig do
|
|
81
|
+
params(
|
|
82
|
+
template_path: String,
|
|
83
|
+
lsp_range: T::Hash[String, T.untyped]
|
|
84
|
+
).returns(T.nilable(T::Hash[String, T.untyped]))
|
|
85
|
+
end
|
|
86
|
+
def ruby_range_to_template(template_path, lsp_range)
|
|
87
|
+
start_pos = ruby_to_template(template_path, lsp_range['start'] || lsp_range[:start])
|
|
88
|
+
end_pos = ruby_to_template(template_path, lsp_range['end'] || lsp_range[:end])
|
|
89
|
+
return nil unless start_pos && end_pos
|
|
90
|
+
|
|
91
|
+
{ 'start' => start_pos, 'end' => end_pos }
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'logger'
|
|
5
|
+
require 'set'
|
|
6
|
+
|
|
7
|
+
module SorbetView
|
|
8
|
+
module Lsp
|
|
9
|
+
class Server
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
sig { params(input: IO, output: IO).void }
|
|
13
|
+
def initialize(input: $stdin, output: $stdout)
|
|
14
|
+
@config = T.let(Configuration.load, Configuration)
|
|
15
|
+
@logger = T.let(Logger.new(File.open('sorbet_view_lsp.log', 'a')), Logger)
|
|
16
|
+
@transport = T.let(Transport.new(input: input, output: output), Transport)
|
|
17
|
+
@document_store = T.let(DocumentStore.new, DocumentStore)
|
|
18
|
+
@uri_mapper = T.let(UriMapper.new(config: @config), UriMapper)
|
|
19
|
+
@position_translator = T.let(PositionTranslator.new, PositionTranslator)
|
|
20
|
+
@compiler = T.let(Compiler::TemplateCompiler.new(config: @config), Compiler::TemplateCompiler)
|
|
21
|
+
@component_compiler = T.let(Compiler::ComponentCompiler.new(config: @config), Compiler::ComponentCompiler)
|
|
22
|
+
@output_manager = T.let(FileSystem::OutputManager.new(@config.output_dir), FileSystem::OutputManager)
|
|
23
|
+
@sorbet = T.let(SorbetProcess.new(config: @config, logger: @logger), SorbetProcess)
|
|
24
|
+
@file_watcher = T.let(nil, T.nilable(FileSystem::FileWatcher))
|
|
25
|
+
@initialized = T.let(false, T::Boolean)
|
|
26
|
+
@shutdown = T.let(false, T::Boolean)
|
|
27
|
+
@sorbet_open_uris = T.let(Set.new, T::Set[String])
|
|
28
|
+
@component_version = T.let(0, Integer)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
sig { void }
|
|
32
|
+
def start
|
|
33
|
+
@logger.info('SorbetView LSP server starting')
|
|
34
|
+
|
|
35
|
+
loop do
|
|
36
|
+
message = @transport.read_message
|
|
37
|
+
break if message.nil?
|
|
38
|
+
|
|
39
|
+
handle_message(message)
|
|
40
|
+
break if @shutdown
|
|
41
|
+
end
|
|
42
|
+
rescue => e
|
|
43
|
+
@logger.error("Server error: #{e.message}\n#{e.backtrace&.join("\n")}")
|
|
44
|
+
ensure
|
|
45
|
+
@file_watcher&.stop
|
|
46
|
+
@sorbet.stop
|
|
47
|
+
@logger.info('SorbetView LSP server stopped')
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
sig { params(message: T::Hash[String, T.untyped]).void }
|
|
53
|
+
def handle_message(message)
|
|
54
|
+
method_name = message['method']
|
|
55
|
+
id = message['id']
|
|
56
|
+
|
|
57
|
+
@logger.debug("Received: #{method_name || 'response'} (id=#{id})")
|
|
58
|
+
|
|
59
|
+
case method_name
|
|
60
|
+
when 'initialize'
|
|
61
|
+
handle_initialize(message)
|
|
62
|
+
when 'initialized'
|
|
63
|
+
handle_initialized(message)
|
|
64
|
+
when 'shutdown'
|
|
65
|
+
handle_shutdown(message)
|
|
66
|
+
when 'exit'
|
|
67
|
+
@shutdown = true
|
|
68
|
+
when 'textDocument/didOpen'
|
|
69
|
+
handle_did_open(message)
|
|
70
|
+
when 'textDocument/didChange'
|
|
71
|
+
handle_did_change(message)
|
|
72
|
+
when 'textDocument/didClose'
|
|
73
|
+
handle_did_close(message)
|
|
74
|
+
when 'textDocument/didSave'
|
|
75
|
+
handle_did_save(message)
|
|
76
|
+
when 'textDocument/hover'
|
|
77
|
+
handle_proxied_request(message)
|
|
78
|
+
when 'textDocument/completion'
|
|
79
|
+
handle_proxied_request(message)
|
|
80
|
+
when 'textDocument/definition'
|
|
81
|
+
handle_proxied_request(message)
|
|
82
|
+
when 'textDocument/references'
|
|
83
|
+
handle_proxied_request(message)
|
|
84
|
+
when 'textDocument/signatureHelp'
|
|
85
|
+
handle_proxied_request(message)
|
|
86
|
+
else
|
|
87
|
+
# Pass through to Sorbet for anything else
|
|
88
|
+
forward_to_sorbet(message)
|
|
89
|
+
end
|
|
90
|
+
rescue => e
|
|
91
|
+
@logger.error("Error handling #{message['method']}: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}")
|
|
92
|
+
@transport.send_error(message['id'], -32603, e.message) if message['id']
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# --- Lifecycle ---
|
|
96
|
+
|
|
97
|
+
sig { params(message: T::Hash[String, T.untyped]).void }
|
|
98
|
+
def handle_initialize(message)
|
|
99
|
+
# Compile all templates first
|
|
100
|
+
compile_all_templates
|
|
101
|
+
|
|
102
|
+
# Start Sorbet LSP
|
|
103
|
+
@sorbet.start
|
|
104
|
+
|
|
105
|
+
# Register handler for diagnostics from Sorbet
|
|
106
|
+
@sorbet.on_notification('textDocument/publishDiagnostics') do |msg|
|
|
107
|
+
handle_sorbet_diagnostics(msg)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Forward initialize to Sorbet and merge capabilities
|
|
111
|
+
sorbet_result = @sorbet.send_request('initialize', message['params'])
|
|
112
|
+
|
|
113
|
+
capabilities = sorbet_result&.dig('result', 'capabilities') || {}
|
|
114
|
+
|
|
115
|
+
# Respond with merged capabilities
|
|
116
|
+
@transport.send_response(message['id'], {
|
|
117
|
+
'capabilities' => capabilities.merge({
|
|
118
|
+
'textDocumentSync' => {
|
|
119
|
+
'openClose' => true,
|
|
120
|
+
'change' => 1, # Full sync
|
|
121
|
+
'save' => { 'includeText' => true }
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
sig { params(message: T::Hash[String, T.untyped]).void }
|
|
128
|
+
def handle_initialized(message)
|
|
129
|
+
@initialized = true
|
|
130
|
+
@sorbet.send_notification('initialized', {})
|
|
131
|
+
start_file_watcher
|
|
132
|
+
@logger.info('SorbetView LSP initialized')
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
sig { params(message: T::Hash[String, T.untyped]).void }
|
|
136
|
+
def handle_shutdown(message)
|
|
137
|
+
@sorbet.send_request('shutdown', nil)
|
|
138
|
+
@transport.send_response(message['id'], nil)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# --- Document Sync ---
|
|
142
|
+
|
|
143
|
+
sig { params(message: T::Hash[String, T.untyped]).void }
|
|
144
|
+
def handle_did_open(message)
|
|
145
|
+
params = message['params']
|
|
146
|
+
td = params['textDocument']
|
|
147
|
+
uri = td['uri']
|
|
148
|
+
text = td['text']
|
|
149
|
+
version = td['version'] || 0
|
|
150
|
+
|
|
151
|
+
if @uri_mapper.template_uri?(uri)
|
|
152
|
+
doc = @document_store.open(uri, text, version)
|
|
153
|
+
compile_and_sync(doc)
|
|
154
|
+
else
|
|
155
|
+
# Forward .rb files to Sorbet; also recompile if they contain erb_template
|
|
156
|
+
forward_to_sorbet(message)
|
|
157
|
+
compile_component_if_needed(uri, text)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
sig { params(message: T::Hash[String, T.untyped]).void }
|
|
162
|
+
def handle_did_change(message)
|
|
163
|
+
params = message['params']
|
|
164
|
+
uri = params['textDocument']['uri']
|
|
165
|
+
version = params['textDocument']['version'] || 0
|
|
166
|
+
|
|
167
|
+
if @uri_mapper.template_uri?(uri)
|
|
168
|
+
# Full sync: take the last content change
|
|
169
|
+
changes = params['contentChanges']
|
|
170
|
+
text = changes&.last&.fetch('text', '')
|
|
171
|
+
doc = @document_store.change(uri, text, version)
|
|
172
|
+
compile_and_sync(doc) if doc
|
|
173
|
+
else
|
|
174
|
+
forward_to_sorbet(message)
|
|
175
|
+
changes = params['contentChanges']
|
|
176
|
+
text = changes&.last&.fetch('text', nil)
|
|
177
|
+
compile_component_if_needed(uri, text) if text
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
sig { params(message: T::Hash[String, T.untyped]).void }
|
|
182
|
+
def handle_did_close(message)
|
|
183
|
+
uri = message.dig('params', 'textDocument', 'uri')
|
|
184
|
+
|
|
185
|
+
if @uri_mapper.template_uri?(uri)
|
|
186
|
+
@document_store.close(uri)
|
|
187
|
+
template_path = @uri_mapper.uri_to_path(uri)
|
|
188
|
+
@position_translator.unregister(template_path)
|
|
189
|
+
else
|
|
190
|
+
forward_to_sorbet(message)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
sig { params(message: T::Hash[String, T.untyped]).void }
|
|
195
|
+
def handle_did_save(message)
|
|
196
|
+
uri = message.dig('params', 'textDocument', 'uri')
|
|
197
|
+
text = message.dig('params', 'text')
|
|
198
|
+
|
|
199
|
+
if @uri_mapper.template_uri?(uri)
|
|
200
|
+
doc = @document_store.get(uri)
|
|
201
|
+
compile_and_sync(doc) if doc
|
|
202
|
+
else
|
|
203
|
+
compile_component_if_needed(uri, text)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# --- Proxied Requests (hover, completion, definition, etc.) ---
|
|
208
|
+
|
|
209
|
+
sig { params(message: T::Hash[String, T.untyped]).void }
|
|
210
|
+
def handle_proxied_request(message)
|
|
211
|
+
params = message['params']
|
|
212
|
+
uri = params.dig('textDocument', 'uri')
|
|
213
|
+
|
|
214
|
+
if @uri_mapper.template_uri?(uri)
|
|
215
|
+
handle_template_proxied_request(message, uri)
|
|
216
|
+
elsif try_handle_component_proxied_request(message, uri)
|
|
217
|
+
# Handled as component heredoc
|
|
218
|
+
else
|
|
219
|
+
forward_to_sorbet(message)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
sig { params(message: T::Hash[String, T.untyped], uri: String).void }
|
|
224
|
+
def handle_template_proxied_request(message, uri)
|
|
225
|
+
Perf.measure("lsp.proxy.#{message['method']}") do
|
|
226
|
+
params = message['params']
|
|
227
|
+
template_path = @uri_mapper.uri_to_path(uri)
|
|
228
|
+
ruby_uri = @uri_mapper.template_to_ruby_uri(uri)
|
|
229
|
+
position = params['position']
|
|
230
|
+
|
|
231
|
+
# Translate position from template to Ruby
|
|
232
|
+
ruby_position = @position_translator.template_to_ruby(template_path, position)
|
|
233
|
+
unless ruby_position
|
|
234
|
+
# Cursor is in HTML, not Ruby code
|
|
235
|
+
@transport.send_response(message['id'], nil)
|
|
236
|
+
next
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Rewrite request for Sorbet
|
|
240
|
+
rewritten = message.dup
|
|
241
|
+
rewritten['params'] = params.merge({
|
|
242
|
+
'textDocument' => { 'uri' => ruby_uri },
|
|
243
|
+
'position' => ruby_position
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
# Forward to Sorbet
|
|
247
|
+
result = Perf.measure('lsp.sorbet_roundtrip') do
|
|
248
|
+
@sorbet.send_request(message['method'], rewritten['params'])
|
|
249
|
+
end
|
|
250
|
+
sorbet_result = result&.fetch('result', nil)
|
|
251
|
+
|
|
252
|
+
# Translate response back to template coordinates
|
|
253
|
+
translated = translate_result(message['method'], template_path, uri, sorbet_result)
|
|
254
|
+
@transport.send_response(message['id'], translated)
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Try to handle a proxied request for a .rb component with erb_template heredoc.
|
|
259
|
+
# Returns true if handled, false if not a component or cursor is outside heredoc.
|
|
260
|
+
sig { params(message: T::Hash[String, T.untyped], uri: String).returns(T::Boolean) }
|
|
261
|
+
def try_handle_component_proxied_request(message, uri)
|
|
262
|
+
path = @uri_mapper.uri_to_path(uri)
|
|
263
|
+
@logger.debug("try_handle_component: path=#{path} end_with_rb=#{path.end_with?('.rb')}")
|
|
264
|
+
return false unless path.end_with?('.rb')
|
|
265
|
+
|
|
266
|
+
# Check if we have a source map registered for this component
|
|
267
|
+
sm = @position_translator.source_map_for(path)
|
|
268
|
+
@logger.debug("try_handle_component: source_map=#{sm ? 'found' : 'nil'} registered_keys=#{@position_translator.registered_paths.inspect}")
|
|
269
|
+
return false unless sm
|
|
270
|
+
|
|
271
|
+
params = message['params']
|
|
272
|
+
position = params['position']
|
|
273
|
+
|
|
274
|
+
# Try translating — returns nil if cursor is outside heredoc Ruby code
|
|
275
|
+
ruby_position = @position_translator.template_to_ruby(path, position)
|
|
276
|
+
@logger.debug("try_handle_component: position=#{position.inspect} -> ruby_position=#{ruby_position.inspect}")
|
|
277
|
+
return false unless ruby_position
|
|
278
|
+
|
|
279
|
+
ruby_uri = @uri_mapper.component_to_ruby_uri(uri)
|
|
280
|
+
|
|
281
|
+
# Rewrite request for the generated __erb_template.rb
|
|
282
|
+
rewritten = message.dup
|
|
283
|
+
rewritten['params'] = params.merge({
|
|
284
|
+
'textDocument' => { 'uri' => ruby_uri },
|
|
285
|
+
'position' => ruby_position
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
# Forward to Sorbet
|
|
289
|
+
result = @sorbet.send_request(message['method'], rewritten['params'])
|
|
290
|
+
sorbet_result = result&.fetch('result', nil)
|
|
291
|
+
|
|
292
|
+
# Translate response back to .rb file coordinates
|
|
293
|
+
translated = translate_result(message['method'], path, uri, sorbet_result)
|
|
294
|
+
@transport.send_response(message['id'], translated)
|
|
295
|
+
true
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# --- Sorbet Diagnostics ---
|
|
299
|
+
|
|
300
|
+
sig { params(message: T::Hash[String, T.untyped]).void }
|
|
301
|
+
def handle_sorbet_diagnostics(message)
|
|
302
|
+
params = message['params']
|
|
303
|
+
uri = params['uri']
|
|
304
|
+
|
|
305
|
+
# Check if this is a generated Ruby file
|
|
306
|
+
template_uri = @uri_mapper.ruby_to_template_uri(uri)
|
|
307
|
+
@logger.debug("Diagnostics for #{uri} -> template_uri=#{template_uri.inspect}")
|
|
308
|
+
unless template_uri
|
|
309
|
+
# Not a generated file, pass through
|
|
310
|
+
@transport.send_notification('textDocument/publishDiagnostics', params)
|
|
311
|
+
return
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
template_path = @uri_mapper.uri_to_path(template_uri)
|
|
315
|
+
raw_diags = params['diagnostics'] || []
|
|
316
|
+
@logger.debug("#{raw_diags.length} raw diagnostics for #{template_path}")
|
|
317
|
+
|
|
318
|
+
# Translate each diagnostic's range back to template coordinates
|
|
319
|
+
translated_diagnostics = raw_diags.filter_map do |diag|
|
|
320
|
+
range = diag['range']
|
|
321
|
+
next nil unless range
|
|
322
|
+
|
|
323
|
+
template_range = @position_translator.ruby_range_to_template(template_path, range)
|
|
324
|
+
@logger.debug(" diag ruby=#{range.inspect} -> template=#{template_range.inspect}")
|
|
325
|
+
next nil unless template_range # Skip diagnostics in boilerplate
|
|
326
|
+
|
|
327
|
+
diag.merge(
|
|
328
|
+
'range' => template_range,
|
|
329
|
+
'source' => 'sorbet_view'
|
|
330
|
+
)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
@logger.debug("Sending #{translated_diagnostics.length} translated diagnostics to #{template_uri}")
|
|
334
|
+
@transport.send_notification('textDocument/publishDiagnostics', {
|
|
335
|
+
'uri' => template_uri,
|
|
336
|
+
'diagnostics' => translated_diagnostics
|
|
337
|
+
})
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# --- Result Translation ---
|
|
341
|
+
|
|
342
|
+
sig do
|
|
343
|
+
params(
|
|
344
|
+
method_name: String,
|
|
345
|
+
template_path: String,
|
|
346
|
+
template_uri: String,
|
|
347
|
+
result: T.untyped
|
|
348
|
+
).returns(T.untyped)
|
|
349
|
+
end
|
|
350
|
+
def translate_result(method_name, template_path, template_uri, result)
|
|
351
|
+
return nil if result.nil?
|
|
352
|
+
|
|
353
|
+
case method_name
|
|
354
|
+
when 'textDocument/hover'
|
|
355
|
+
translate_hover(template_path, result)
|
|
356
|
+
when 'textDocument/completion'
|
|
357
|
+
translate_completion(template_path, result)
|
|
358
|
+
when 'textDocument/definition', 'textDocument/references'
|
|
359
|
+
translate_locations(template_path, template_uri, result)
|
|
360
|
+
else
|
|
361
|
+
result
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
sig { params(template_path: String, result: T::Hash[String, T.untyped]).returns(T.untyped) }
|
|
366
|
+
def translate_hover(template_path, result)
|
|
367
|
+
return result unless result.is_a?(Hash) && result['range']
|
|
368
|
+
|
|
369
|
+
range = @position_translator.ruby_range_to_template(template_path, result['range'])
|
|
370
|
+
return result unless range
|
|
371
|
+
|
|
372
|
+
result.merge('range' => range)
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
sig { params(template_path: String, result: T.untyped).returns(T.untyped) }
|
|
376
|
+
def translate_completion(template_path, result)
|
|
377
|
+
return result unless result.is_a?(Hash)
|
|
378
|
+
|
|
379
|
+
items = result['items'] || result
|
|
380
|
+
return result unless items.is_a?(Array)
|
|
381
|
+
|
|
382
|
+
translated_items = items.map do |item|
|
|
383
|
+
if item.is_a?(Hash) && item.dig('textEdit', 'range')
|
|
384
|
+
range = @position_translator.ruby_range_to_template(template_path, item['textEdit']['range'])
|
|
385
|
+
if range
|
|
386
|
+
item.merge('textEdit' => item['textEdit'].merge('range' => range))
|
|
387
|
+
else
|
|
388
|
+
item
|
|
389
|
+
end
|
|
390
|
+
else
|
|
391
|
+
item
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
if result.is_a?(Hash) && result.key?('items')
|
|
396
|
+
result.merge('items' => translated_items)
|
|
397
|
+
else
|
|
398
|
+
translated_items
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
sig { params(template_path: String, template_uri: String, result: T.untyped).returns(T.untyped) }
|
|
403
|
+
def translate_locations(template_path, template_uri, result)
|
|
404
|
+
return result unless result.is_a?(Array)
|
|
405
|
+
|
|
406
|
+
result.filter_map do |loc|
|
|
407
|
+
next loc unless loc.is_a?(Hash)
|
|
408
|
+
|
|
409
|
+
loc_uri = loc['uri'] || loc.dig('targetUri')
|
|
410
|
+
target_template_uri = @uri_mapper.ruby_to_template_uri(loc_uri)
|
|
411
|
+
|
|
412
|
+
if target_template_uri
|
|
413
|
+
# Location points to a generated file, translate back
|
|
414
|
+
target_path = @uri_mapper.uri_to_path(target_template_uri)
|
|
415
|
+
range_key = loc.key?('targetRange') ? 'targetRange' : 'range'
|
|
416
|
+
range = loc[range_key]
|
|
417
|
+
translated_range = @position_translator.ruby_range_to_template(target_path, range)
|
|
418
|
+
next nil unless translated_range
|
|
419
|
+
|
|
420
|
+
uri_key = loc.key?('targetUri') ? 'targetUri' : 'uri'
|
|
421
|
+
loc.merge(uri_key => target_template_uri, range_key => translated_range)
|
|
422
|
+
else
|
|
423
|
+
loc
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# --- Compilation ---
|
|
429
|
+
|
|
430
|
+
sig { params(doc: Document).void }
|
|
431
|
+
def compile_and_sync(doc)
|
|
432
|
+
Perf.measure('lsp.compile_and_sync') do
|
|
433
|
+
template_path = @uri_mapper.uri_to_path(doc.uri)
|
|
434
|
+
result = @compiler.compile(template_path, doc.content)
|
|
435
|
+
doc.compile_result = result
|
|
436
|
+
|
|
437
|
+
# Write generated .rb to disk
|
|
438
|
+
@output_manager.write(result)
|
|
439
|
+
|
|
440
|
+
# Register source map
|
|
441
|
+
@position_translator.register(template_path, result.source_map)
|
|
442
|
+
|
|
443
|
+
# Notify Sorbet about the changed .rb file
|
|
444
|
+
ruby_uri = @uri_mapper.template_to_ruby_uri(doc.uri)
|
|
445
|
+
if @sorbet_open_uris.include?(ruby_uri)
|
|
446
|
+
@sorbet.send_notification('textDocument/didChange', {
|
|
447
|
+
'textDocument' => { 'uri' => ruby_uri, 'version' => doc.version },
|
|
448
|
+
'contentChanges' => [{ 'text' => result.ruby_source }]
|
|
449
|
+
})
|
|
450
|
+
else
|
|
451
|
+
@sorbet.send_notification('textDocument/didOpen', {
|
|
452
|
+
'textDocument' => {
|
|
453
|
+
'uri' => ruby_uri,
|
|
454
|
+
'languageId' => 'ruby',
|
|
455
|
+
'version' => doc.version,
|
|
456
|
+
'text' => result.ruby_source
|
|
457
|
+
}
|
|
458
|
+
})
|
|
459
|
+
@sorbet_open_uris.add(ruby_uri)
|
|
460
|
+
end
|
|
461
|
+
end # Perf.measure
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
sig { params(uri: String, text: T.nilable(String)).void }
|
|
465
|
+
def compile_component_if_needed(uri, text)
|
|
466
|
+
path = @uri_mapper.uri_to_path(uri)
|
|
467
|
+
return unless path.end_with?('.rb')
|
|
468
|
+
|
|
469
|
+
source = if text
|
|
470
|
+
text
|
|
471
|
+
else
|
|
472
|
+
# Try mapped path first, then original URI path
|
|
473
|
+
if File.exist?(path)
|
|
474
|
+
File.read(path)
|
|
475
|
+
else
|
|
476
|
+
raw_path = URI.decode_www_form_component(URI.parse(uri).path || '')
|
|
477
|
+
unless File.exist?(raw_path)
|
|
478
|
+
@logger.debug("compile_component: file not found at #{path} or #{raw_path}")
|
|
479
|
+
return
|
|
480
|
+
end
|
|
481
|
+
File.read(raw_path)
|
|
482
|
+
end
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
unless Compiler::HeredocExtractor.contains_erb_template?(source)
|
|
486
|
+
return
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
@logger.debug("compile_component: compiling #{path}")
|
|
490
|
+
results = @component_compiler.compile(path, source)
|
|
491
|
+
results.each do |result|
|
|
492
|
+
@output_manager.write(result)
|
|
493
|
+
@position_translator.register(path, result.source_map)
|
|
494
|
+
|
|
495
|
+
@component_version += 1
|
|
496
|
+
ruby_uri = @uri_mapper.path_to_uri(result.source_map.ruby_path)
|
|
497
|
+
if @sorbet_open_uris.include?(ruby_uri)
|
|
498
|
+
@sorbet.send_notification('textDocument/didChange', {
|
|
499
|
+
'textDocument' => { 'uri' => ruby_uri, 'version' => @component_version },
|
|
500
|
+
'contentChanges' => [{ 'text' => result.ruby_source }]
|
|
501
|
+
})
|
|
502
|
+
else
|
|
503
|
+
@sorbet.send_notification('textDocument/didOpen', {
|
|
504
|
+
'textDocument' => {
|
|
505
|
+
'uri' => ruby_uri,
|
|
506
|
+
'languageId' => 'ruby',
|
|
507
|
+
'version' => @component_version,
|
|
508
|
+
'text' => result.ruby_source
|
|
509
|
+
}
|
|
510
|
+
})
|
|
511
|
+
@sorbet_open_uris.add(ruby_uri)
|
|
512
|
+
end
|
|
513
|
+
end
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
sig { void }
|
|
517
|
+
def compile_all_templates
|
|
518
|
+
Perf.reset!
|
|
519
|
+
templates = FileSystem::ProjectScanner.scan(@config)
|
|
520
|
+
@logger.info("Compiling #{templates.length} templates")
|
|
521
|
+
|
|
522
|
+
Perf.measure('lsp.compile_all_templates') do
|
|
523
|
+
templates.each do |path|
|
|
524
|
+
source = File.read(path)
|
|
525
|
+
result = @compiler.compile(path, source)
|
|
526
|
+
|
|
527
|
+
next if result.source_map.entries.empty? && @config.skip_missing_locals && requires_locals?(path)
|
|
528
|
+
|
|
529
|
+
@output_manager.write(result)
|
|
530
|
+
@position_translator.register(path, result.source_map)
|
|
531
|
+
end
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
compile_all_components
|
|
535
|
+
Perf.report_to_logger(@logger)
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
sig { void }
|
|
539
|
+
def compile_all_components
|
|
540
|
+
components = FileSystem::ProjectScanner.scan_components(@config)
|
|
541
|
+
@logger.info("Compiling #{components.length} component(s) with erb_template")
|
|
542
|
+
|
|
543
|
+
components.each do |path|
|
|
544
|
+
results = @component_compiler.compile_file(path)
|
|
545
|
+
results.each do |result|
|
|
546
|
+
@output_manager.write(result)
|
|
547
|
+
@position_translator.register(path, result.source_map)
|
|
548
|
+
end
|
|
549
|
+
end
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
sig { params(path: String).returns(T::Boolean) }
|
|
553
|
+
def requires_locals?(path)
|
|
554
|
+
basename = File.basename(path)
|
|
555
|
+
basename.start_with?('_') || basename.end_with?('.turbo_stream.erb')
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
# --- File Watching ---
|
|
559
|
+
|
|
560
|
+
sig { void }
|
|
561
|
+
def start_file_watcher
|
|
562
|
+
@file_watcher = FileSystem::FileWatcher.new(config: @config) do |modified, added, removed|
|
|
563
|
+
handle_file_changes(modified, added, removed)
|
|
564
|
+
end
|
|
565
|
+
@file_watcher.start
|
|
566
|
+
@logger.info("FileWatcher started for: #{@config.input_dirs.join(', ')}")
|
|
567
|
+
rescue => e
|
|
568
|
+
@logger.warn("Failed to start FileWatcher: #{e.message}")
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
sig { params(modified: T::Array[String], added: T::Array[String], removed: T::Array[String]).void }
|
|
572
|
+
def handle_file_changes(modified, added, removed)
|
|
573
|
+
(modified + added).each do |path|
|
|
574
|
+
# Skip files already open in the editor (editor manages those)
|
|
575
|
+
template_uri = @uri_mapper.path_to_uri(path)
|
|
576
|
+
next if @document_store.get(template_uri)
|
|
577
|
+
|
|
578
|
+
@logger.debug("File changed on disk: #{path}")
|
|
579
|
+
source = File.read(path)
|
|
580
|
+
|
|
581
|
+
if path.end_with?('.rb')
|
|
582
|
+
# Component .rb file — compile and notify Sorbet
|
|
583
|
+
compile_component_if_needed(template_uri, source)
|
|
584
|
+
else
|
|
585
|
+
result = @compiler.compile(path, source)
|
|
586
|
+
next if result.source_map.entries.empty? && @config.skip_missing_locals && requires_locals?(path)
|
|
587
|
+
|
|
588
|
+
@output_manager.write(result)
|
|
589
|
+
@position_translator.register(path, result.source_map)
|
|
590
|
+
end
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
removed.each do |path|
|
|
594
|
+
@logger.debug("File removed: #{path}")
|
|
595
|
+
@position_translator.unregister(path)
|
|
596
|
+
end
|
|
597
|
+
rescue => e
|
|
598
|
+
@logger.error("FileWatcher callback error: #{e.message}")
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
sig { params(message: T::Hash[String, T.untyped]).void }
|
|
602
|
+
def forward_to_sorbet(message)
|
|
603
|
+
@sorbet.forward(message)
|
|
604
|
+
end
|
|
605
|
+
end
|
|
606
|
+
end
|
|
607
|
+
end
|