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.
- checksums.yaml +4 -4
- data/ATTRIBUTION.md +1 -1
- data/CHANGELOG.md +50 -0
- data/lib/rubik/ast/cache_store.rb +4 -2
- data/lib/rubik/ast/graph.rb +49 -94
- data/lib/rubik/ast/graph_extensions.rb +202 -0
- data/lib/rubik/ast/ruby_extractor.rb +8 -3
- data/lib/rubik/ast/symbol.rb +7 -0
- data/lib/rubik/ast/treesitter/base_walker.rb +4 -3
- data/lib/rubik/ast/treesitter/c_walker.rb +10 -8
- data/lib/rubik/ast/treesitter/go_walker.rb +4 -2
- data/lib/rubik/ast/treesitter/rust_walker.rb +9 -7
- data/lib/rubik/ast/treesitter/typescript_walker.rb +7 -5
- data/lib/rubik/ast/treesitter/zig_walker.rb +44 -6
- data/lib/rubik/lsp/client.rb +102 -24
- data/lib/rubik/lsp/manager.rb +136 -83
- data/lib/rubik/orchestrator/tool_circuit_breaker.rb +15 -5
- data/lib/rubik/prompt/token_estimator.rb +15 -3
- data/lib/rubik/repl/ast_warmup.rb +35 -4
- data/lib/rubik/repl/shell.rb +1 -1
- data/lib/rubik/tools/builtins/symbols.rb +56 -20
- data/lib/rubik/tools/symbol_tool_support.rb +113 -0
- data/lib/rubik/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fc6b8b3ff7cb67e302bbb3a74237615515318acde17b8c41193a293dadef7576
|
|
4
|
+
data.tar.gz: 3c0616b8f9b6544619903234c4595242b95c1998b2602aca584334eaf19fcc5d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e714d3f5fb612958caba5ed92829af4262b55ac50390d5ed9262cdb8c81143886d613424c13f1fe585c4debead0758469b4aeadea855edcc02d14ef5b9eb5d9b
|
|
7
|
+
data.tar.gz: cef0f0645353fe8c6f4413c39f6adff50c810c51720022774db779bce54de99a2555396f211a62af336be0be5c4a534f1c14c616a180c3c3344790ca56235772
|
data/ATTRIBUTION.md
CHANGED
|
@@ -121,7 +121,7 @@ SOFTWARE.
|
|
|
121
121
|
| **`strings-ansi`** | Piotr Murach | MIT | ANSI-aware string truncation and alignment for REPL formatters. |
|
|
122
122
|
| **`reline`** | Hiroshi SHIBATA & Ruby Team | Ruby License / BSD-2-Clause | Line-editing, history navigation, autocompletion hooks, and headless terminal adaptation. |
|
|
123
123
|
| **`prism`** | Kevin Newton & Ruby Core Team | MIT | High-performance Ruby parsing and AST extraction ([`Rubik::AST::RubyExtractor`](lib/rubik/ast/ruby_extractor.rb)). |
|
|
124
|
-
| **`minitest`** | Ryan Davis & seattlerb | MIT | Fast, modular test suite powering Rubik's 3,
|
|
124
|
+
| **`minitest`** | Ryan Davis & seattlerb | MIT | Fast, modular test suite powering Rubik's 3,800+ unit, integration, and contract tests. |
|
|
125
125
|
| **`rubocop`** | Bozhidar Batsov & contributors | MIT | Static code analysis, style enforcement, and architectural hygiene gates. |
|
|
126
126
|
| **`benchmark`, `logger`, `pstore`, `reline`, `fiddle`** | Ruby core / rubygems teams | Ruby License / BSD-2-Clause | Ruby stdlib gems pinned explicitly in `rubik.gemspec` as they are unbundled from the default set in Ruby 3.4/3.5. |
|
|
127
127
|
|
data/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,56 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
10
10
|
|
|
11
11
|
Nothing yet.
|
|
12
12
|
|
|
13
|
+
## [0.3.2] - 2026-10-21
|
|
14
|
+
|
|
15
|
+
A reliability patch: LSP requests tell timeouts and crashes apart and
|
|
16
|
+
recover atomically, AST symbol lookup answers precisely, deep
|
|
17
|
+
tree-sitter input no longer overflows the stack, and the tool circuit
|
|
18
|
+
breaker's half-open state actually gates.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **LSP requests distinguish failures and recover (#214, closes
|
|
23
|
+
#206, #196):** a successful JSON `null` result is no longer
|
|
24
|
+
misread as a timeout — hover, definition and references return the
|
|
25
|
+
proper error outcome; a dead client's recovery is atomic, so
|
|
26
|
+
concurrent callers all land on the same new client or get a
|
|
27
|
+
specific recovery error; queries and
|
|
28
|
+
didOpen/didChange/didClose normalize URIs identically
|
|
29
|
+
(case-preserving document keys on Windows); server-to-client
|
|
30
|
+
requests are handled before response correlation;
|
|
31
|
+
`workspace/configuration` returns empty settings objects and
|
|
32
|
+
unsupported methods answer `-32601`.
|
|
33
|
+
- **AST symbol lookup is precise (#213, closes #204, #195, #197):**
|
|
34
|
+
qualified symbol identities are preserved and ambiguous bare names
|
|
35
|
+
come back with their candidate lists; tree-sitter and Ruby call
|
|
36
|
+
extraction keep receiver information, so self-recursive calls are
|
|
37
|
+
retained; symbol search output is sorted and bounded; warmup,
|
|
38
|
+
extraction errors and argument types are reported explicitly;
|
|
39
|
+
indexed file keys resolve through symlinked parent paths; the AST
|
|
40
|
+
cache validates its schema version and guards missing symbol files
|
|
41
|
+
instead of serving stale lookups.
|
|
42
|
+
- **Tree-sitter walkers cap recursion depth (#203, closes #202 and
|
|
43
|
+
#191):** Go, C/C++, TypeScript and Zig walkers stop at 50 levels —
|
|
44
|
+
Zig's `find_function`/`find_identifier` helpers included — so
|
|
45
|
+
3,000-layer-deep inputs no longer overflow the stack; deeper
|
|
46
|
+
subtrees are deliberately treated as leaves.
|
|
47
|
+
- **The tool circuit breaker's half-open state is repaired (#215):**
|
|
48
|
+
half-open admits exactly one probe — the token is consumed on
|
|
49
|
+
entry and concurrent or subsequent calls are denied while the
|
|
50
|
+
probe is in flight — and a `cooldown_elapsed?` ArgumentError that
|
|
51
|
+
broke recovery paths is gone.
|
|
52
|
+
|
|
53
|
+
### Changed
|
|
54
|
+
|
|
55
|
+
- **TokenEstimator is ~3.4x faster on tool-call-heavy histories
|
|
56
|
+
(#216):** `tool_calls` and `tool_schemas` estimation skips the
|
|
57
|
+
deep UTF-8 clean/re-serialize path, cutting allocations ~90% with
|
|
58
|
+
bit-for-bit identical output — statusline updates, loop iterations
|
|
59
|
+
and compaction checks stop paying that tax on every message.
|
|
60
|
+
- **`callers_of` lookups use a Set (#190):** reverse-call indexing
|
|
61
|
+
drops redundant membership scans.
|
|
62
|
+
|
|
13
63
|
## [0.3.1] - 2026-10-19
|
|
14
64
|
|
|
15
65
|
A hygiene patch: the Truncator honors the budgets you give it across
|
|
@@ -9,6 +9,8 @@ require_relative "../platform"
|
|
|
9
9
|
module Rubik
|
|
10
10
|
module AST
|
|
11
11
|
class CacheStore
|
|
12
|
+
SCHEMA_VERSION = 2
|
|
13
|
+
|
|
12
14
|
LANGUAGE_MAP = {
|
|
13
15
|
".rb" => "ruby",
|
|
14
16
|
".py" => "python",
|
|
@@ -46,7 +48,7 @@ module Rubik
|
|
|
46
48
|
return false unless file_symbols_hash.is_a?(Hash)
|
|
47
49
|
|
|
48
50
|
data = {
|
|
49
|
-
"version" =>
|
|
51
|
+
"version" => SCHEMA_VERSION,
|
|
50
52
|
"last_scan" => Time.now.to_i,
|
|
51
53
|
"files" => {}
|
|
52
54
|
}
|
|
@@ -106,7 +108,7 @@ module Rubik
|
|
|
106
108
|
|
|
107
109
|
content = File.read(target_path)
|
|
108
110
|
data = JSON.parse(content)
|
|
109
|
-
return {} unless data.is_a?(Hash) && data["files"].is_a?(Hash)
|
|
111
|
+
return {} unless data.is_a?(Hash) && data["version"] == SCHEMA_VERSION && data["files"].is_a?(Hash)
|
|
110
112
|
|
|
111
113
|
result = {}
|
|
112
114
|
data["files"].each do |rel_path, cached_info|
|
data/lib/rubik/ast/graph.rb
CHANGED
|
@@ -38,13 +38,15 @@ module Rubik
|
|
|
38
38
|
@file_symbols = Hash.new { |h, k| h[k] = [] }
|
|
39
39
|
@extraction_status = {}
|
|
40
40
|
@mutex = Mutex.new
|
|
41
|
+
@warmup_generation = 0
|
|
42
|
+
@warmup_status = { state: :ready, indexed: 0, total: nil, generation: 0 }
|
|
41
43
|
end
|
|
42
44
|
|
|
43
45
|
# Last extraction status recorded for a file (issue #147): :ok,
|
|
44
46
|
# :partial, :failed, or :unavailable. Lets callers tell "no symbols"
|
|
45
47
|
# apart from "extraction failed" without re-running the extractor.
|
|
46
48
|
def extraction_status(file_path)
|
|
47
|
-
@mutex.synchronize {
|
|
49
|
+
@mutex.synchronize { _get_extraction_status_unlocked(file_path) }
|
|
48
50
|
end
|
|
49
51
|
|
|
50
52
|
def symbols
|
|
@@ -54,7 +56,11 @@ module Rubik
|
|
|
54
56
|
end
|
|
55
57
|
|
|
56
58
|
def file_symbols
|
|
57
|
-
@mutex.synchronize
|
|
59
|
+
@mutex.synchronize do
|
|
60
|
+
@file_symbols.each_with_object(Hash.new { |hash, path| hash[path] = [] }) do |(file_path, symbols), snapshot|
|
|
61
|
+
snapshot[file_path] = symbols.dup
|
|
62
|
+
end
|
|
63
|
+
end
|
|
58
64
|
end
|
|
59
65
|
|
|
60
66
|
def clear
|
|
@@ -63,6 +69,8 @@ module Rubik
|
|
|
63
69
|
@qualified_symbols.clear
|
|
64
70
|
@file_symbols.clear
|
|
65
71
|
@extraction_status.clear
|
|
72
|
+
@warmup_generation += 1
|
|
73
|
+
@warmup_status = { state: :ready, indexed: 0, total: nil, generation: @warmup_generation }
|
|
66
74
|
end
|
|
67
75
|
end
|
|
68
76
|
|
|
@@ -74,8 +82,7 @@ module Rubik
|
|
|
74
82
|
|
|
75
83
|
def remove_file(file_path)
|
|
76
84
|
@mutex.synchronize do
|
|
77
|
-
_remove_file_unlocked(file_path)
|
|
78
|
-
@extraction_status.delete(file_path)
|
|
85
|
+
_remove_file_unlocked(file_path, clear_status: true)
|
|
79
86
|
end
|
|
80
87
|
end
|
|
81
88
|
|
|
@@ -106,57 +113,30 @@ module Rubik
|
|
|
106
113
|
def save_to_cache(cache_store)
|
|
107
114
|
return false unless cache_store
|
|
108
115
|
|
|
109
|
-
snapshot = @mutex.synchronize
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
# Duplicate-free symbol search: iterates the live map under the mutex
|
|
114
|
-
# instead of copying it via #symbols (callers only read immutable
|
|
115
|
-
# attributes, so handing out the Symbol objects is as safe as the
|
|
116
|
-
# snapshot was — it shared the very same objects).
|
|
117
|
-
def search_symbols(query)
|
|
118
|
-
q = query.to_s.downcase
|
|
119
|
-
@mutex.synchronize do
|
|
120
|
-
@symbols.values.select { |sym| sym.name.downcase.include?(q) }
|
|
121
|
-
end
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
# The callers of `symbol_name` are the symbols whose `calls` list
|
|
125
|
-
# actually references it. `symbol_name` may arrive either as a plain
|
|
126
|
-
# identifier (e.g. "add") or as a qualified key (e.g.
|
|
127
|
-
# "rust:src/lib.rs##Config::new") — the `!refs` / callers flow does the
|
|
128
|
-
# latter. We resolve it to the set of concrete callee spellings it maps
|
|
129
|
-
# to (plain name plus every qualified key that owns that name), then keep
|
|
130
|
-
# the symbols whose calls contain one of those spellings. This mirrors the
|
|
131
|
-
# plain-then-qualified lookup used by #smart_context_for / #symbol_definition
|
|
132
|
-
# and avoids the previous backwards test, which flagged any symbol merely
|
|
133
|
-
# mentioning the substring as a caller.
|
|
134
|
-
def callers_of(symbol_name)
|
|
135
|
-
@mutex.synchronize do
|
|
136
|
-
target_names = caller_targets(symbol_name)
|
|
137
|
-
return [] if target_names.empty?
|
|
138
|
-
|
|
139
|
-
@symbols.values.uniq.select do |sym|
|
|
140
|
-
sym.calls.any? { |c| target_names.include?(c) }
|
|
116
|
+
snapshot = @mutex.synchronize do
|
|
117
|
+
@file_symbols.each_with_object({}) do |(file_path, symbols), copy|
|
|
118
|
+
copy[file_path] = symbols.dup
|
|
141
119
|
end
|
|
142
120
|
end
|
|
121
|
+
cache_store.save(snapshot)
|
|
143
122
|
end
|
|
144
123
|
|
|
145
124
|
def smart_context_for(symbol_name)
|
|
146
125
|
@mutex.synchronize do
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
@symbols.values.find { |s| s.name == symbol_name || s.name.end_with?("##{symbol_name}") }
|
|
150
|
-
return "" unless target
|
|
126
|
+
candidates = _symbol_candidate_pairs_unlocked(symbol_name)
|
|
127
|
+
return "" unless candidates.length == 1
|
|
151
128
|
|
|
129
|
+
target = candidates.first.last
|
|
152
130
|
context_blocks = []
|
|
153
131
|
context_blocks << "# Target symbol: #{target.name} (#{target.file}:#{target.line_start}-#{target.line_end})"
|
|
154
132
|
context_blocks << target.body if target.body
|
|
155
133
|
|
|
156
134
|
target.calls.each do |called_name|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
135
|
+
dep_candidates = _symbol_candidate_pairs_unlocked(called_name).map(&:last)
|
|
136
|
+
target_scope = _symbol_scope(target.name)
|
|
137
|
+
matching = dep_candidates.find { |s| s.file && target.file && Rubik::Platform.path_equal?(s.file, target.file) && _symbol_scope(s.name) == target_scope } ||
|
|
138
|
+
dep_candidates.find { |s| s.file && target.file && Rubik::Platform.path_equal?(s.file, target.file) } ||
|
|
139
|
+
(dep_candidates.first if dep_candidates.length == 1)
|
|
160
140
|
if matching
|
|
161
141
|
context_blocks << "# Dependency signature: #{matching.name}"
|
|
162
142
|
context_blocks << matching.signature
|
|
@@ -183,50 +163,8 @@ module Rubik
|
|
|
183
163
|
end
|
|
184
164
|
end
|
|
185
165
|
|
|
186
|
-
def symbol_definition(symbol_name)
|
|
187
|
-
@mutex.synchronize do
|
|
188
|
-
target = @symbols[symbol_name] ||
|
|
189
|
-
@qualified_symbols[symbol_name] ||
|
|
190
|
-
@symbols.values.find do |s|
|
|
191
|
-
s.name.downcase == symbol_name.downcase ||
|
|
192
|
-
s.name.end_with?("##{symbol_name}") ||
|
|
193
|
-
s.name.end_with?(".#{symbol_name}")
|
|
194
|
-
end
|
|
195
|
-
return nil unless target
|
|
196
|
-
|
|
197
|
-
{
|
|
198
|
-
name: target.name,
|
|
199
|
-
kind: target.kind,
|
|
200
|
-
file: target.file,
|
|
201
|
-
line_start: target.line_start,
|
|
202
|
-
line_end: target.line_end,
|
|
203
|
-
signature: target.signature,
|
|
204
|
-
body: target.body,
|
|
205
|
-
docstring: target.docstring,
|
|
206
|
-
calls: target.calls
|
|
207
|
-
}
|
|
208
|
-
end
|
|
209
|
-
end
|
|
210
|
-
|
|
211
166
|
private
|
|
212
167
|
|
|
213
|
-
# Concrete callee spellings that a reference to `symbol_name` might use.
|
|
214
|
-
# `symbol_name` may be a bare identifier ("Config::new") or a qualified key
|
|
215
|
-
# ("rust:src/lib.rs##Config::new"); we pull the trailing identifier in both
|
|
216
|
-
# cases and collect every symbol that owns that name so a call site is
|
|
217
|
-
# matched whether it wrote the bare identifier or the fully-qualified key.
|
|
218
|
-
# Caller MUST hold @mutex.
|
|
219
|
-
def caller_targets(symbol_name)
|
|
220
|
-
bare = symbol_name.split("##").last.to_s
|
|
221
|
-
return [] if bare.empty?
|
|
222
|
-
|
|
223
|
-
names = [bare]
|
|
224
|
-
@symbols.each_value do |sym|
|
|
225
|
-
names << sym.name if sym.name == bare || sym.name.end_with?("##{bare}")
|
|
226
|
-
end
|
|
227
|
-
names.uniq
|
|
228
|
-
end
|
|
229
|
-
|
|
230
168
|
def _index_file_unlocked(file_path, content = nil)
|
|
231
169
|
result = Extractor.extract_result(file_path, content)
|
|
232
170
|
@extraction_status[file_path] = result.status
|
|
@@ -259,14 +197,16 @@ module Rubik
|
|
|
259
197
|
extracted
|
|
260
198
|
end
|
|
261
199
|
|
|
262
|
-
def _remove_file_unlocked(file_path)
|
|
200
|
+
def _remove_file_unlocked(file_path, clear_status: false)
|
|
201
|
+
if clear_status
|
|
202
|
+
@extraction_status.delete(file_path)
|
|
203
|
+
status_key = _matching_key_for_path(@extraction_status, file_path)
|
|
204
|
+
@extraction_status.delete(status_key) if status_key
|
|
205
|
+
end
|
|
206
|
+
|
|
263
207
|
existing = @file_symbols.delete(file_path)
|
|
264
208
|
if existing.nil? || existing.empty?
|
|
265
|
-
|
|
266
|
-
matching_key = @file_symbols.keys.find do |k|
|
|
267
|
-
Rubik::Platform.path_equal?(k, file_path) ||
|
|
268
|
-
Rubik::Platform.normalize_path(File.expand_path(k)) == canonical_target
|
|
269
|
-
end
|
|
209
|
+
matching_key = _matching_key_for_path(@file_symbols, file_path)
|
|
270
210
|
existing = @file_symbols.delete(matching_key) if matching_key
|
|
271
211
|
end
|
|
272
212
|
|
|
@@ -286,13 +226,28 @@ module Rubik
|
|
|
286
226
|
syms = @file_symbols[file_path]
|
|
287
227
|
return syms if syms && !syms.empty?
|
|
288
228
|
|
|
229
|
+
matching_key = _matching_key_for_path(@file_symbols, file_path)
|
|
230
|
+
(matching_key && @file_symbols[matching_key]) || []
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def _get_extraction_status_unlocked(file_path)
|
|
234
|
+
return @extraction_status[file_path] if @extraction_status.key?(file_path)
|
|
235
|
+
|
|
236
|
+
matching_key = _matching_key_for_path(@extraction_status, file_path)
|
|
237
|
+
matching_key ? @extraction_status[matching_key] : nil
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def _matching_key_for_path(map, file_path)
|
|
289
241
|
canonical_target = Rubik::Platform.normalize_path(File.expand_path(file_path))
|
|
290
|
-
|
|
242
|
+
resolved_target = Rubik::Platform.normalize_path(Rubik::Platform.resolved_path_best_effort(file_path))
|
|
243
|
+
map.keys.find do |k|
|
|
291
244
|
Rubik::Platform.path_equal?(k, file_path) ||
|
|
292
|
-
Rubik::Platform.normalize_path(File.expand_path(k)) == canonical_target
|
|
245
|
+
Rubik::Platform.normalize_path(File.expand_path(k)) == canonical_target ||
|
|
246
|
+
Rubik::Platform.normalize_path(Rubik::Platform.resolved_path_best_effort(k)) == resolved_target
|
|
293
247
|
end
|
|
294
|
-
(matching_key && @file_symbols[matching_key]) || []
|
|
295
248
|
end
|
|
296
249
|
end
|
|
297
250
|
end
|
|
298
251
|
end
|
|
252
|
+
|
|
253
|
+
require_relative "graph_extensions"
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubik
|
|
4
|
+
module AST
|
|
5
|
+
class SymbolMap
|
|
6
|
+
def values
|
|
7
|
+
qualified_values = @qualified_symbols.sort_by { |key, _symbol| key }.map(&:last)
|
|
8
|
+
qualified_ids = {}.compare_by_identity
|
|
9
|
+
qualified_values.each { |symbol| qualified_ids[symbol] = true }
|
|
10
|
+
super.reject { |symbol| qualified_ids.key?(symbol) } + qualified_values
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def size
|
|
14
|
+
values.length
|
|
15
|
+
end
|
|
16
|
+
alias length size
|
|
17
|
+
|
|
18
|
+
def empty?
|
|
19
|
+
super && @qualified_symbols.empty?
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class Graph
|
|
24
|
+
class AmbiguousSymbolError < StandardError
|
|
25
|
+
attr_reader :query, :candidates
|
|
26
|
+
|
|
27
|
+
def initialize(query, candidates)
|
|
28
|
+
@query = query
|
|
29
|
+
@candidates = candidates
|
|
30
|
+
super("Ambiguous symbol '#{query}'. Use a qualified key. Candidates:\n- #{candidates.join("\n- ")}")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def begin_warmup(total: nil)
|
|
35
|
+
@mutex.synchronize do
|
|
36
|
+
@warmup_generation += 1
|
|
37
|
+
@warmup_status = { state: :warming, indexed: 0, total: total, generation: @warmup_generation }
|
|
38
|
+
@warmup_generation
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def update_warmup(generation, indexed: nil, total: nil)
|
|
43
|
+
@mutex.synchronize do
|
|
44
|
+
return false unless generation == @warmup_status[:generation] && @warmup_status[:state] == :warming
|
|
45
|
+
|
|
46
|
+
@warmup_status[:indexed] = [indexed, 0].max unless indexed.nil?
|
|
47
|
+
@warmup_status[:total] = [total, 0].max unless total.nil?
|
|
48
|
+
true
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def finish_warmup(generation, state: :ready)
|
|
53
|
+
@mutex.synchronize do
|
|
54
|
+
return false unless generation == @warmup_status[:generation]
|
|
55
|
+
|
|
56
|
+
@warmup_status[:state] = state
|
|
57
|
+
true
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def warmup_status
|
|
62
|
+
@mutex.synchronize { @warmup_status.dup }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Search each qualified definition once and return deterministic relevance order.
|
|
66
|
+
def search_symbols(query)
|
|
67
|
+
q = query.to_s.downcase
|
|
68
|
+
return [] if q.empty?
|
|
69
|
+
|
|
70
|
+
@mutex.synchronize do
|
|
71
|
+
entries = @qualified_symbols.sort_by { |key, _symbol| key }
|
|
72
|
+
entries = @symbols.map { |name, sym| ["#{sym.file}##{name}", sym] }.sort_by(&:first) if entries.empty?
|
|
73
|
+
entries.select { |_key, sym| sym.name.downcase.include?(q) }
|
|
74
|
+
.sort_by { |key, sym| [_search_rank(sym.name.downcase, q), key] }
|
|
75
|
+
.map(&:last)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def symbol_candidates(symbol_name)
|
|
80
|
+
@mutex.synchronize { _symbol_candidate_pairs_unlocked(symbol_name).map(&:first) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# A bare query with multiple definitions is ambiguous; unresolved receiver
|
|
84
|
+
# dispatches remain in the call list without being assigned to a symbol.
|
|
85
|
+
def callers_of(symbol_name)
|
|
86
|
+
@mutex.synchronize do
|
|
87
|
+
candidates = _symbol_candidate_pairs_unlocked(symbol_name)
|
|
88
|
+
return [] if candidates.empty?
|
|
89
|
+
raise AmbiguousSymbolError.new(symbol_name, candidates.map(&:first)) if candidates.length > 1
|
|
90
|
+
|
|
91
|
+
target_key, target = candidates.first
|
|
92
|
+
target_names = caller_targets(symbol_name)
|
|
93
|
+
target_name_count = _symbol_candidate_pairs_unlocked(_symbol_leaf(target.name)).length
|
|
94
|
+
target_scoped_count = _symbol_candidate_pairs_unlocked(target.name).length
|
|
95
|
+
known_symbols = @qualified_symbols.values.uniq
|
|
96
|
+
known_symbols = @symbols.values.uniq if known_symbols.empty?
|
|
97
|
+
known_symbols.select do |caller|
|
|
98
|
+
caller.calls.any? do |call|
|
|
99
|
+
_call_matches_symbol?(call, caller, target, target_key, target_names, target_name_count, target_scoped_count)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def symbol_definition(symbol_name)
|
|
106
|
+
@mutex.synchronize do
|
|
107
|
+
candidates = _symbol_candidate_pairs_unlocked(symbol_name)
|
|
108
|
+
return nil unless candidates.length == 1
|
|
109
|
+
|
|
110
|
+
target = candidates.first.last
|
|
111
|
+
{
|
|
112
|
+
name: target.name,
|
|
113
|
+
kind: target.kind,
|
|
114
|
+
file: target.file,
|
|
115
|
+
line_start: target.line_start,
|
|
116
|
+
line_end: target.line_end,
|
|
117
|
+
signature: target.signature,
|
|
118
|
+
body: target.body,
|
|
119
|
+
docstring: target.docstring,
|
|
120
|
+
calls: target.calls
|
|
121
|
+
}
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def _search_rank(name, query)
|
|
128
|
+
return 0 if name == query
|
|
129
|
+
return 1 if name.start_with?(query)
|
|
130
|
+
|
|
131
|
+
2
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def caller_targets(symbol_name)
|
|
135
|
+
candidates = _symbol_candidate_pairs_unlocked(symbol_name)
|
|
136
|
+
return [] unless candidates.length == 1
|
|
137
|
+
|
|
138
|
+
key, symbol = candidates.first
|
|
139
|
+
[key, symbol.name].uniq
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def _symbol_candidate_pairs_unlocked(symbol_name)
|
|
143
|
+
query = symbol_name.to_s
|
|
144
|
+
return [] if query.empty?
|
|
145
|
+
|
|
146
|
+
exact = @qualified_symbols[query]
|
|
147
|
+
return [[query, exact]] if exact
|
|
148
|
+
|
|
149
|
+
pairs = @qualified_symbols.to_a
|
|
150
|
+
pairs = @symbols.map { |name, sym| ["#{sym.file}##{name}", sym] } if pairs.empty?
|
|
151
|
+
folded = query.downcase
|
|
152
|
+
matched = pairs.select do |_key, symbol|
|
|
153
|
+
name = symbol.name.to_s
|
|
154
|
+
leaf = _symbol_leaf(name)
|
|
155
|
+
name.downcase == folded || leaf.downcase == folded ||
|
|
156
|
+
name.downcase.end_with?("##{folded}", ".#{folded}", "::#{folded}")
|
|
157
|
+
end
|
|
158
|
+
matched.sort_by(&:first).uniq(&:first)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def _symbol_leaf(name)
|
|
162
|
+
name.to_s.split(/[#.:]/).last.to_s
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def _symbol_scope(name)
|
|
166
|
+
value = name.to_s
|
|
167
|
+
separator = [value.rindex("#"), value.rindex("."), value.rindex("::")].compact.max
|
|
168
|
+
separator ? value[0...separator] : nil
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def _normalized_call_name(name)
|
|
172
|
+
name.to_s.gsub("::", "#").gsub(".", "#").sub(/!\z/, "")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def _call_matches_symbol?(call, caller, target, target_key, target_names, target_name_count, target_scoped_count = target_name_count)
|
|
176
|
+
value = call.to_s.strip.sub(/!\z/, "")
|
|
177
|
+
normalized = _normalized_call_name(value)
|
|
178
|
+
target_name = _normalized_call_name(target.name)
|
|
179
|
+
same_file = caller.file && target.file && Rubik::Platform.path_equal?(caller.file, target.file)
|
|
180
|
+
return true if value == target_key
|
|
181
|
+
return true if normalized == target_name && (target_scoped_count == 1 || same_file)
|
|
182
|
+
|
|
183
|
+
leaf = _symbol_leaf(value)
|
|
184
|
+
return false unless leaf == _symbol_leaf(target.name)
|
|
185
|
+
return true if target_name_count == 1 && !value.match?(/[.#:]|->/)
|
|
186
|
+
|
|
187
|
+
call_scope = _symbol_scope(value)
|
|
188
|
+
target_scope = _symbol_scope(target.name)
|
|
189
|
+
caller_scope = _symbol_scope(caller.name)
|
|
190
|
+
return true if call_scope == target_scope && call_scope && same_file
|
|
191
|
+
|
|
192
|
+
caller_recv = caller_scope && caller_scope[/\A\(\s*([A-Za-z_]\w*)\b/, 1]
|
|
193
|
+
self_scopes = ["self", "this", caller_recv].compact
|
|
194
|
+
return true if self_scopes.include?(call_scope) && caller_scope == target_scope && same_file
|
|
195
|
+
|
|
196
|
+
# Bare calls are local to one lexical scope; unknown receiver calls stay unresolved.
|
|
197
|
+
lexical_scope_matches = target_scope ? caller_scope == target_scope : caller_scope.nil?
|
|
198
|
+
!value.match?(/[.#:]|->/) && target_names.include?(target.name) && same_file && lexical_scope_matches
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -141,14 +141,19 @@ module Rubik
|
|
|
141
141
|
end
|
|
142
142
|
end
|
|
143
143
|
|
|
144
|
-
def extract_calls_from_node(node,
|
|
144
|
+
def extract_calls_from_node(node, _self_name)
|
|
145
145
|
calls = []
|
|
146
146
|
queue = [node]
|
|
147
147
|
|
|
148
148
|
while (curr = queue.pop)
|
|
149
149
|
if curr.is_a?(Prism::CallNode)
|
|
150
150
|
cname = curr.name.to_s
|
|
151
|
-
|
|
151
|
+
if curr.receiver
|
|
152
|
+
receiver = curr.receiver.slice.to_s
|
|
153
|
+
operator = curr.call_operator || "."
|
|
154
|
+
cname = "#{receiver}#{operator}#{cname}"
|
|
155
|
+
end
|
|
156
|
+
calls << cname unless ["require", "require_relative", "raise", "puts", "p", "print"].include?(cname)
|
|
152
157
|
end
|
|
153
158
|
queue.concat(curr.compact_child_nodes) if curr.respond_to?(:compact_child_nodes)
|
|
154
159
|
end
|
|
@@ -187,7 +192,7 @@ module Rubik
|
|
|
187
192
|
calls = []
|
|
188
193
|
calls_src.scan(/\b([a-z_][a-zA-Z0-9_]*)\s*\(/) do |m|
|
|
189
194
|
c = m[0]
|
|
190
|
-
calls << c unless
|
|
195
|
+
calls << c unless EXCLUDED_CALLS.include?(c) || calls.include?(c)
|
|
191
196
|
end
|
|
192
197
|
|
|
193
198
|
symbols << Symbol.new(
|
data/lib/rubik/ast/symbol.rb
CHANGED
|
@@ -45,6 +45,13 @@ module Rubik
|
|
|
45
45
|
end
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
+
def body_available?
|
|
49
|
+
return true if @body || @body_loader
|
|
50
|
+
return false if @file.nil? || !File.exist?(@file)
|
|
51
|
+
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
|
|
48
55
|
def self.read_source_range(file, l_start, l_end)
|
|
49
56
|
return nil unless file && l_start && File.exist?(file)
|
|
50
57
|
|
|
@@ -79,9 +79,10 @@ module Rubik
|
|
|
79
79
|
if call_kinds.include?(n.kind)
|
|
80
80
|
fn = n.child_by_field_name("function") || n.child_by_field_name("name") || n.named_child(0)
|
|
81
81
|
if fn
|
|
82
|
-
|
|
83
|
-
#
|
|
84
|
-
|
|
82
|
+
# Keep receiver and namespace text. Stripping this to a bare
|
|
83
|
+
# method name made dynamic dispatch look like a call to every
|
|
84
|
+
# same-named definition in the graph.
|
|
85
|
+
fn_name = text_for(fn).strip
|
|
85
86
|
calls << fn_name if fn_name && !fn_name.empty?
|
|
86
87
|
end
|
|
87
88
|
end
|
|
@@ -49,7 +49,9 @@ module Rubik
|
|
|
49
49
|
# `scope` is the enclosing declaration path (namespaces, then the
|
|
50
50
|
# innermost class, outermost first); `member` marks a class body so
|
|
51
51
|
# declarations are read as member declarations.
|
|
52
|
-
def walk_children(node, symbols, scope, member:)
|
|
52
|
+
def walk_children(node, symbols, scope, member:, depth: 0)
|
|
53
|
+
return if depth > 50
|
|
54
|
+
|
|
53
55
|
(0...node.named_child_count).each do |i|
|
|
54
56
|
child = node.named_child(i)
|
|
55
57
|
case child.kind
|
|
@@ -58,9 +60,9 @@ module Rubik
|
|
|
58
60
|
when "type_definition"
|
|
59
61
|
walk_typedef(child, symbols, scope)
|
|
60
62
|
when *RECORD_KINDS.keys
|
|
61
|
-
walk_record(child, symbols, scope)
|
|
63
|
+
walk_record(child, symbols, scope, depth: depth + 1)
|
|
62
64
|
when "namespace_definition"
|
|
63
|
-
walk_namespace(child, symbols, scope)
|
|
65
|
+
walk_namespace(child, symbols, scope, depth: depth + 1)
|
|
64
66
|
when "function_definition"
|
|
65
67
|
walk_function(child, symbols, scope, member: member)
|
|
66
68
|
when "field_declaration", "declaration"
|
|
@@ -69,7 +71,7 @@ module Rubik
|
|
|
69
71
|
# Plain recursion also unwraps template_declaration nodes (their
|
|
70
72
|
# inner class/function is walked in place) and linkage
|
|
71
73
|
# specifications.
|
|
72
|
-
walk_children(child, symbols, scope, member: member)
|
|
74
|
+
walk_children(child, symbols, scope, member: member, depth: depth + 1)
|
|
73
75
|
end
|
|
74
76
|
end
|
|
75
77
|
end
|
|
@@ -104,7 +106,7 @@ module Rubik
|
|
|
104
106
|
)
|
|
105
107
|
end
|
|
106
108
|
|
|
107
|
-
def walk_record(node, symbols, scope)
|
|
109
|
+
def walk_record(node, symbols, scope, depth:)
|
|
108
110
|
name_node = node.child_by_field_name("name")
|
|
109
111
|
return unless name_node
|
|
110
112
|
|
|
@@ -122,16 +124,16 @@ module Rubik
|
|
|
122
124
|
)
|
|
123
125
|
|
|
124
126
|
body = node.child_by_field_name("body")
|
|
125
|
-
walk_children(body, symbols, scope + [name], member: true) if body
|
|
127
|
+
walk_children(body, symbols, scope + [name], member: true, depth: depth + 1) if body
|
|
126
128
|
end
|
|
127
129
|
|
|
128
130
|
# Namespace names extend the qualification path; everything inside is
|
|
129
131
|
# walked with the namespace prefix (app::run, app::Widget#run).
|
|
130
|
-
def walk_namespace(node, symbols, scope)
|
|
132
|
+
def walk_namespace(node, symbols, scope, depth:)
|
|
131
133
|
name_node = node.child_by_field_name("name")
|
|
132
134
|
ns_scope = name_node ? scope + [text_for(name_node).strip] : scope
|
|
133
135
|
body = node.child_by_field_name("body")
|
|
134
|
-
walk_children(body, symbols, ns_scope, member: false) if body
|
|
136
|
+
walk_children(body, symbols, ns_scope, member: false, depth: depth + 1) if body
|
|
135
137
|
end
|
|
136
138
|
|
|
137
139
|
def walk_function(node, symbols, scope, member:)
|
|
@@ -16,7 +16,9 @@ module Rubik
|
|
|
16
16
|
|
|
17
17
|
private
|
|
18
18
|
|
|
19
|
-
def walk_children(node, symbols)
|
|
19
|
+
def walk_children(node, symbols, 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
|
|
@@ -31,7 +33,7 @@ module Rubik
|
|
|
31
33
|
when "import_declaration"
|
|
32
34
|
walk_import(child, symbols)
|
|
33
35
|
else
|
|
34
|
-
walk_children(child, symbols)
|
|
36
|
+
walk_children(child, symbols, depth: depth + 1)
|
|
35
37
|
end
|
|
36
38
|
end
|
|
37
39
|
end
|