rubik 0.3.1 → 0.3.2

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.
@@ -16,7 +16,9 @@ module Rubik
16
16
 
17
17
  private
18
18
 
19
- def walk_children(node, symbols, impl_type)
19
+ def walk_children(node, symbols, impl_type, depth: 0)
20
+ return if depth > 50
21
+
20
22
  (0...node.named_child_count).each do |i|
21
23
  child = node.named_child(i)
22
24
  case child.kind
@@ -25,13 +27,13 @@ module Rubik
25
27
  when "enum_item"
26
28
  walk_enum(child, symbols)
27
29
  when "impl_item"
28
- walk_impl(child, symbols)
30
+ walk_impl(child, symbols, depth: depth + 1)
29
31
  when "function_item"
30
32
  walk_function(child, symbols, impl_type)
31
33
  when "use_declaration"
32
34
  walk_use(child, symbols)
33
35
  else
34
- walk_children(child, symbols, impl_type)
36
+ walk_children(child, symbols, impl_type, depth: depth + 1)
35
37
  end
36
38
  end
37
39
  end
@@ -64,12 +66,12 @@ module Rubik
64
66
  )
65
67
  end
66
68
 
67
- def walk_impl(node, symbols)
69
+ def walk_impl(node, symbols, depth: 0)
68
70
  type_node = node.child_by_field_name("type")
69
71
  impl_name = type_node ? text_for(type_node).strip : nil
70
72
 
71
73
  body = node.child_by_field_name("body")
72
- walk_children(body, symbols, impl_name) if body
74
+ walk_children(body, symbols, impl_name, depth: depth + 1) if body
73
75
  end
74
76
 
75
77
  def walk_function(node, symbols, impl_type)
@@ -110,13 +112,13 @@ module Rubik
110
112
  if n.kind == "call_expression"
111
113
  fn = n.child_by_field_name("function")
112
114
  if fn
113
- fn_text = text_for(fn).split("::").last&.strip
115
+ fn_text = text_for(fn).strip
114
116
  calls << fn_text if fn_text && !fn_text.empty?
115
117
  end
116
118
  elsif n.kind == "macro_invocation"
117
119
  macro = n.child_by_field_name("macro") || n.named_child(0)
118
120
  if macro
119
- macro_text = text_for(macro).sub(/!\z/, "").split("::").last&.strip
121
+ macro_text = text_for(macro).sub(/!\z/, "").strip
120
122
  calls << macro_text if macro_text && !macro_text.empty?
121
123
  end
122
124
  end
@@ -16,12 +16,14 @@ module Rubik
16
16
 
17
17
  private
18
18
 
19
- def walk_children(node, symbols, current_class)
19
+ def walk_children(node, symbols, current_class, depth: 0)
20
+ return if depth > 50
21
+
20
22
  (0...node.named_child_count).each do |i|
21
23
  child = node.named_child(i)
22
24
  case child.kind
23
25
  when "export_statement"
24
- walk_export(child, symbols, current_class)
26
+ walk_export(child, symbols, current_class, depth: depth + 1)
25
27
  when "interface_declaration"
26
28
  walk_interface(child, symbols)
27
29
  when "enum_declaration"
@@ -35,12 +37,12 @@ module Rubik
35
37
  when "import_statement"
36
38
  walk_import(child, symbols)
37
39
  else
38
- walk_children(child, symbols, current_class)
40
+ walk_children(child, symbols, current_class, depth: depth + 1)
39
41
  end
40
42
  end
41
43
  end
42
44
 
43
- def walk_export(node, symbols, current_class)
45
+ def walk_export(node, symbols, current_class, depth:)
44
46
  (0...node.named_child_count).each do |i|
45
47
  child = node.named_child(i)
46
48
  case child.kind
@@ -49,7 +51,7 @@ module Rubik
49
51
  when "class_declaration" then walk_class(child, symbols)
50
52
  when "function_declaration" then walk_function(child, symbols)
51
53
  when "lexical_declaration", "variable_declaration" then walk_variable_declaration(child, symbols)
52
- else walk_children(child, symbols, current_class)
54
+ else walk_children(child, symbols, current_class, depth: depth + 1)
53
55
  end
54
56
  end
55
57
  end
@@ -19,14 +19,50 @@ module Rubik
19
19
 
20
20
  private
21
21
 
22
- def walk_children(node, symbols)
22
+ # Zig represents a method receiver as the prefix of a SuffixExpr while
23
+ # FieldOrFnCall contains only the trailing `.method()` node. Preserve
24
+ # the full expression so unresolved receiver dispatch is not flattened
25
+ # into a call to every same-named function.
26
+ def extract_calls(node, call_kinds = ["call", "call_expression", "invocation"])
27
+ return super unless call_kinds.include?("FieldOrFnCall")
28
+
29
+ calls = []
30
+ stack = [node]
31
+ while (current = stack.pop)
32
+ if current.kind == "FieldOrFnCall"
33
+ args_node = (0...current.named_child_count).map { |i| current.named_child(i) }
34
+ .find { |child| child.kind == "FnCallArguments" }
35
+ if args_node
36
+ suffix = current.parent
37
+ start_byte = suffix&.kind == "SuffixExpr" ? suffix.start_byte : current.start_byte
38
+ fn_text = (@code.byteslice(start_byte...args_node.start_byte) || "").strip
39
+ calls << fn_text unless fn_text.empty?
40
+ end
41
+ elsif current.kind == "SuffixExpr" &&
42
+ current.named_child_count >= 2 &&
43
+ current.named_child(1)&.kind == "FnCallArguments"
44
+ fn_text = text_for(current.named_child(0)).strip
45
+ calls << fn_text unless fn_text.empty?
46
+ elsif call_kinds.include?(current.kind)
47
+ fn = current.child_by_field_name("function") || current.child_by_field_name("name") || current.named_child(0)
48
+ fn_text = text_for(fn).strip if fn
49
+ calls << fn_text if fn_text && !fn_text.empty?
50
+ end
51
+ (0...current.named_child_count).reverse_each { |i| stack.push(current.named_child(i)) }
52
+ end
53
+ calls.uniq
54
+ end
55
+
56
+ def walk_children(node, symbols, depth: 0)
57
+ return if depth > 50
58
+
23
59
  (0...node.named_child_count).each do |i|
24
60
  child = node.named_child(i)
25
61
  case child.kind
26
62
  when "Decl"
27
63
  walk_decl(child, symbols)
28
64
  else
29
- walk_children(child, symbols)
65
+ walk_children(child, symbols, depth: depth + 1)
30
66
  end
31
67
  end
32
68
  end
@@ -82,11 +118,12 @@ module Rubik
82
118
  # Locates the node that owns this Decl's function definition. Zig
83
119
  # exposes it under several kind names across grammar versions, so we
84
120
  # accept every one we know about rather than only "FnProto".
85
- def find_function(node)
121
+ def find_function(node, depth: 0)
122
+ return if depth > 50
86
123
  return node if FN_KINDS.include?(node.kind)
87
124
 
88
125
  (0...node.named_child_count).each do |i|
89
- found = find_function(node.named_child(i))
126
+ found = find_function(node.named_child(i), depth: depth + 1)
90
127
  return found if found
91
128
  end
92
129
  nil
@@ -94,11 +131,12 @@ module Rubik
94
131
 
95
132
  # Walks the subtree for the first identifier node — used as a fallback
96
133
  # when the grammar does not expose the function name as a named field.
97
- def find_identifier(node)
134
+ def find_identifier(node, depth: 0)
135
+ return if depth > 50
98
136
  return node if node.kind == "identifier"
99
137
 
100
138
  (0...node.named_child_count).each do |i|
101
- found = find_identifier(node.named_child(i))
139
+ found = find_identifier(node.named_child(i), depth: depth + 1)
102
140
  return found if found
103
141
  end
104
142
  nil
@@ -9,6 +9,15 @@ require_relative "../log"
9
9
  module Rubik
10
10
  module LSP
11
11
  class Client
12
+ class RequestError < StandardError
13
+ attr_reader :reason
14
+
15
+ def initialize(reason, message)
16
+ @reason = reason
17
+ super(message)
18
+ end
19
+ end
20
+
12
21
  SERVERS = {
13
22
  "go" => "gopls",
14
23
  "rust" => "rust-analyzer",
@@ -31,13 +40,14 @@ module Rubik
31
40
  @language = language.to_s.downcase
32
41
  @root_dir = Rubik::Platform.expand_path(root_dir)
33
42
  cmd = server_cmd || SERVERS[@language]
34
- if (cmd.to_s.start_with?("typeprof") || cmd.to_s.start_with?("typescript-language-server")) && !cmd.to_s.include?("--stdio")
43
+ if cmd.to_s.start_with?("typeprof", "typescript-language-server") && !cmd.to_s.include?("--stdio")
35
44
  cmd = "#{cmd} --stdio"
36
45
  elsif cmd.to_s.start_with?("solargraph") && !cmd.to_s.include?("stdio")
37
46
  cmd = "#{cmd} stdio"
38
47
  end
39
48
  @server_cmd = cmd
40
49
  @running = false
50
+ @crashed = false
41
51
  @req_id = 0
42
52
  @stdin = nil
43
53
  @stdout = nil
@@ -49,6 +59,7 @@ module Rubik
49
59
  @cv = ConditionVariable.new
50
60
  @responses = {}
51
61
  @pending_requests = {}
62
+ @reader_failure = nil
52
63
  @diagnostics = {}
53
64
  @open_documents = {}
54
65
  @version_counter = 0
@@ -73,6 +84,7 @@ module Rubik
73
84
  @req_id = 0
74
85
  @responses.clear
75
86
  @pending_requests.clear
87
+ @reader_failure = nil
76
88
  @open_documents.clear
77
89
  @diagnostics.clear
78
90
  @version_counter = 0
@@ -92,6 +104,7 @@ module Rubik
92
104
  @stdin.binmode
93
105
  @stdout.binmode
94
106
  @running = false
107
+ @crashed = false
95
108
  @reader_alive = false
96
109
 
97
110
  start_reader_thread
@@ -138,6 +151,10 @@ module Rubik
138
151
  true
139
152
  end
140
153
 
154
+ def crashed?
155
+ defined?(@crashed) && @crashed == true
156
+ end
157
+
141
158
  def reconnect!
142
159
  stop if @running
143
160
  start
@@ -154,6 +171,7 @@ module Rubik
154
171
  end
155
172
 
156
173
  @running = false
174
+ @crashed = false
157
175
  @reader_alive = false
158
176
  # Drain first: its thread may be blocked in gets on @stderr.
159
177
  @stderr_drain&.stop
@@ -190,9 +208,8 @@ module Rubik
190
208
  def definition(file_path, line, character)
191
209
  return { ok: false, error: "LSP server not running" } unless alive?
192
210
 
193
- abs_path = File.expand_path(file_path, @root_dir)
194
211
  res = send_request("textDocument/definition", {
195
- textDocument: { uri: Rubik::Platform.to_file_uri(abs_path) },
212
+ textDocument: { uri: document_uri(file_path) },
196
213
  position: { line: line, character: character }
197
214
  })
198
215
 
@@ -204,9 +221,8 @@ module Rubik
204
221
  def references(file_path, line, character)
205
222
  return { ok: false, error: "LSP server not running" } unless alive?
206
223
 
207
- abs_path = File.expand_path(file_path, @root_dir)
208
224
  res = send_request("textDocument/references", {
209
- textDocument: { uri: Rubik::Platform.to_file_uri(abs_path) },
225
+ textDocument: { uri: document_uri(file_path) },
210
226
  position: { line: line, character: character },
211
227
  context: { includeDeclaration: true }
212
228
  })
@@ -219,9 +235,8 @@ module Rubik
219
235
  def hover(file_path, line, character)
220
236
  return { ok: false, error: "LSP server not running" } unless alive?
221
237
 
222
- abs_path = File.expand_path(file_path, @root_dir)
223
238
  res = send_request("textDocument/hover", {
224
- textDocument: { uri: Rubik::Platform.to_file_uri(abs_path) },
239
+ textDocument: { uri: document_uri(file_path) },
225
240
  position: { line: line, character: character }
226
241
  })
227
242
 
@@ -233,7 +248,7 @@ module Rubik
233
248
  def did_open(file_path, content, language_id = @language)
234
249
  return false unless alive?
235
250
 
236
- abs_path = Rubik::Platform.normalize_path_raw(Rubik::Platform.expand_path(file_path, @root_dir))
251
+ abs_path = normalized_document_path(file_path)
237
252
  @mutex.synchronize do
238
253
  @version_counter += 1
239
254
  @open_documents[abs_path] = @version_counter
@@ -241,7 +256,7 @@ module Rubik
241
256
 
242
257
  send_notification("textDocument/didOpen", {
243
258
  textDocument: {
244
- uri: Rubik::Platform.to_file_uri(abs_path),
259
+ uri: document_uri(abs_path),
245
260
  languageId: language_id.to_s,
246
261
  version: @version_counter,
247
262
  text: content
@@ -255,7 +270,7 @@ module Rubik
255
270
  def did_change(file_path, content)
256
271
  return false unless alive?
257
272
 
258
- abs_path = Rubik::Platform.normalize_path_raw(Rubik::Platform.expand_path(file_path, @root_dir))
273
+ abs_path = normalized_document_path(file_path)
259
274
  version = @mutex.synchronize do
260
275
  @version_counter += 1
261
276
  @open_documents[abs_path] = @version_counter
@@ -267,7 +282,7 @@ module Rubik
267
282
 
268
283
  send_notification("textDocument/didChange", {
269
284
  textDocument: {
270
- uri: Rubik::Platform.to_file_uri(abs_path),
285
+ uri: document_uri(abs_path),
271
286
  version: version
272
287
  },
273
288
  contentChanges: [
@@ -282,11 +297,16 @@ module Rubik
282
297
  def did_close(file_path)
283
298
  return false unless @running
284
299
 
285
- abs_path = Rubik::Platform.normalize_path_raw(Rubik::Platform.expand_path(file_path, @root_dir))
286
- @mutex.synchronize { @open_documents.delete(abs_path) }
300
+ abs_path = normalized_document_path(file_path)
301
+ raw_abs = Rubik::Platform.expand_path(file_path, @root_dir)
302
+ @mutex.synchronize do
303
+ @open_documents.delete(abs_path)
304
+ @open_documents.delete(raw_abs)
305
+ @open_documents.delete_if { |k, _| Rubik::Platform.path_equal?(k, abs_path) }
306
+ end
287
307
 
288
308
  send_notification("textDocument/didClose", {
289
- textDocument: { uri: Rubik::Platform.to_file_uri(abs_path) }
309
+ textDocument: { uri: document_uri(abs_path) }
290
310
  })
291
311
  true
292
312
  rescue StandardError
@@ -296,18 +316,25 @@ module Rubik
296
316
  def document_open?(file_path)
297
317
  return false if file_path.nil? || file_path.to_s.strip.empty?
298
318
 
299
- abs_path = Rubik::Platform.normalize_path_raw(Rubik::Platform.expand_path(file_path, @root_dir))
300
- @mutex.synchronize { @open_documents.key?(abs_path) }
319
+ abs_path = normalized_document_path(file_path)
320
+ raw_abs = Rubik::Platform.expand_path(file_path, @root_dir)
321
+ @mutex.synchronize do
322
+ @open_documents.key?(abs_path) ||
323
+ @open_documents.key?(raw_abs) ||
324
+ @open_documents.keys.any? { |k| Rubik::Platform.path_equal?(k, abs_path) }
325
+ end
301
326
  rescue ArgumentError
302
327
  false
303
328
  end
304
329
 
305
330
  def diagnostics_for(file_path)
306
- abs_path = Rubik::Platform.normalize_path_raw(Rubik::Platform.expand_path(file_path, @root_dir))
331
+ abs_path = normalized_document_path(file_path)
332
+ raw_abs = Rubik::Platform.normalize_path_raw(Rubik::Platform.expand_path(file_path, @root_dir))
307
333
  root_norm = Rubik::Platform.normalize_path_raw(@root_dir)
308
- rel_path = abs_path.start_with?("#{root_norm}/") ? abs_path.delete_prefix("#{root_norm}/") : file_path.to_s.tr("\\", "/")
334
+ rel_path = raw_abs.start_with?("#{root_norm}/") ? raw_abs.delete_prefix("#{root_norm}/") : file_path.to_s.tr("\\", "/")
309
335
  @mutex.synchronize do
310
336
  @diagnostics[abs_path] ||
337
+ @diagnostics[raw_abs] ||
311
338
  @diagnostics[rel_path] ||
312
339
  @diagnostics[file_path] ||
313
340
  @diagnostics.find { |k, _| Rubik::Platform.path_equal?(k, abs_path) }&.last ||
@@ -358,13 +385,20 @@ module Rubik
358
385
  end
359
386
  end
360
387
  ensure
361
- @reader_alive = false
362
- @running = false
388
+ @mutex.synchronize do
389
+ @crashed = true if @running
390
+ @reader_alive = false
391
+ @running = false
392
+ @reader_failure ||= "LSP response reader stopped before completing the request"
393
+ @cv.broadcast
394
+ end
363
395
  end
364
396
  end
365
397
 
366
398
  def handle_incoming_message(msg)
367
- if msg["id"]
399
+ if msg.key?("method") && msg.key?("id")
400
+ handle_server_request(msg)
401
+ elsif msg.key?("id")
368
402
  req_id = msg["id"]
369
403
  @mutex.synchronize do
370
404
  # Discard late response if request is neither pending nor already tracked
@@ -382,12 +416,14 @@ module Rubik
382
416
  params = msg["params"] || {}
383
417
  uri = params["uri"].to_s
384
418
  raw_path = Rubik::Platform.from_file_uri(uri)
419
+ norm_path = normalized_document_path(raw_path)
385
420
  abs_path = Rubik::Platform.normalize_path_raw(Rubik::Platform.expand_path(raw_path, @root_dir))
386
421
  root_norm = Rubik::Platform.normalize_path_raw(@root_dir)
387
422
  rel_path = abs_path.start_with?("#{root_norm}/") ? abs_path.delete_prefix("#{root_norm}/") : abs_path
388
423
  diags = params["diagnostics"] || []
389
424
 
390
425
  @mutex.synchronize do
426
+ @diagnostics[norm_path] = diags
391
427
  @diagnostics[abs_path] = diags
392
428
  @diagnostics[rel_path] = diags
393
429
  @diagnostics[raw_path] = diags
@@ -413,15 +449,28 @@ module Rubik
413
449
 
414
450
  def wait_for_response(req_id, timeout: 5)
415
451
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
416
- res = nil
417
452
  timed_out = false
453
+ reader_failure = nil
418
454
 
419
455
  @mutex.synchronize do
420
456
  loop do
421
457
  if @responses.key?(req_id)
422
- res = @responses.delete(req_id)["result"]
458
+ response = @responses.delete(req_id)
423
459
  @pending_requests.delete(req_id)
424
- return res
460
+ if response.key?("error")
461
+ error = response["error"] || {}
462
+ raise RequestError.new(:server_error, error["message"] || "LSP server returned an error")
463
+ end
464
+ return response["result"] if response.key?("result")
465
+
466
+ raise RequestError.new(:invalid_response, "LSP response did not contain a result")
467
+ end
468
+
469
+ if @reader_failure
470
+ reader_failure = @reader_failure
471
+ @responses.delete(req_id)
472
+ @pending_requests.delete(req_id)
473
+ break
425
474
  end
426
475
 
427
476
  remaining = timeout - (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time)
@@ -443,11 +492,40 @@ module Rubik
443
492
  rescue StandardError
444
493
  nil
445
494
  end
495
+ raise RequestError.new(:timeout, "LSP request timed out after #{timeout} seconds")
446
496
  end
447
497
 
498
+ raise RequestError.new(:reader_failure, reader_failure) if reader_failure
499
+
448
500
  nil
449
501
  end
450
502
 
503
+ def normalized_document_path(file_path)
504
+ path = Rubik::Platform.expand_path(file_path, @root_dir)
505
+ Rubik::Platform.normalize_path(path)
506
+ end
507
+
508
+ def document_uri(file_path)
509
+ Rubik::Platform.to_file_uri(normalized_document_path(file_path))
510
+ end
511
+
512
+ def handle_server_request(msg)
513
+ case msg["method"]
514
+ when "workspace/configuration"
515
+ params = msg["params"]
516
+ items = params.is_a?(Hash) && params["items"].is_a?(Array) ? params["items"] : []
517
+ write_message({ jsonrpc: "2.0", id: msg["id"], result: items.map { {} } })
518
+ when "client/registerCapability", "client/unregisterCapability", "window/workDoneProgress/create"
519
+ write_message({ jsonrpc: "2.0", id: msg["id"], result: nil })
520
+ else
521
+ write_message({
522
+ jsonrpc: "2.0",
523
+ id: msg["id"],
524
+ error: { code: -32_601, message: "Method not found: #{msg['method']}" }
525
+ })
526
+ end
527
+ end
528
+
451
529
  def send_notification(method, params)
452
530
  msg = {
453
531
  jsonrpc: "2.0",