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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/Rakefile +8 -0
  4. data/exe/sv +6 -0
  5. data/lib/sorbet_view/cli/runner.rb +206 -0
  6. data/lib/sorbet_view/compiler/adapters/erb_adapter.rb +170 -0
  7. data/lib/sorbet_view/compiler/component_compiler.rb +48 -0
  8. data/lib/sorbet_view/compiler/heredoc_extractor.rb +112 -0
  9. data/lib/sorbet_view/compiler/parser_adapter.rb +16 -0
  10. data/lib/sorbet_view/compiler/ruby_generator.rb +211 -0
  11. data/lib/sorbet_view/compiler/ruby_segment.rb +13 -0
  12. data/lib/sorbet_view/compiler/template_compiler.rb +41 -0
  13. data/lib/sorbet_view/compiler/template_context.rb +170 -0
  14. data/lib/sorbet_view/configuration.rb +34 -0
  15. data/lib/sorbet_view/file_system/file_watcher.rb +61 -0
  16. data/lib/sorbet_view/file_system/output_manager.rb +40 -0
  17. data/lib/sorbet_view/file_system/project_scanner.rb +34 -0
  18. data/lib/sorbet_view/lsp/document_store.rb +56 -0
  19. data/lib/sorbet_view/lsp/position_translator.rb +95 -0
  20. data/lib/sorbet_view/lsp/server.rb +607 -0
  21. data/lib/sorbet_view/lsp/sorbet_process.rb +164 -0
  22. data/lib/sorbet_view/lsp/transport.rb +89 -0
  23. data/lib/sorbet_view/lsp/uri_mapper.rb +105 -0
  24. data/lib/sorbet_view/perf.rb +92 -0
  25. data/lib/sorbet_view/source_map/mapping_entry.rb +12 -0
  26. data/lib/sorbet_view/source_map/position.rb +23 -0
  27. data/lib/sorbet_view/source_map/range.rb +35 -0
  28. data/lib/sorbet_view/source_map/source_map.rb +103 -0
  29. data/lib/sorbet_view/version.rb +6 -0
  30. data/lib/sorbet_view.rb +36 -0
  31. data/lib/tapioca/dsl/compilers/sorbet_view.rb +265 -0
  32. data/sorbet_view.gemspec +34 -0
  33. data/vscode/.gitignore +3 -0
  34. data/vscode/.vscode/launch.json +12 -0
  35. data/vscode/.vscodeignore +4 -0
  36. data/vscode/package-lock.json +140 -0
  37. data/vscode/package.json +61 -0
  38. data/vscode/src/extension.ts +88 -0
  39. data/vscode/tsconfig.json +17 -0
  40. 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