rubik 0.3.0 → 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 +84 -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 +105 -26
- 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/fuzzy_matcher.rb +39 -19
- data/lib/rubik/tools/symbol_tool_support.rb +113 -0
- data/lib/rubik/tools/truncator.rb +54 -21
- data/lib/rubik/version.rb +1 -1
- metadata +3 -1
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "output"
|
|
4
|
+
require_relative "diagnostic_reporter"
|
|
5
|
+
require_relative "fuzzy_matcher"
|
|
6
|
+
require_relative "truncator"
|
|
7
|
+
|
|
8
|
+
module Rubik
|
|
9
|
+
module Tools
|
|
10
|
+
module SymbolToolSupport
|
|
11
|
+
def self.string_argument(args, primary, aliases: [], label: primary)
|
|
12
|
+
return [nil, ToolOutput.error("Tool arguments must be an object.")] unless args.is_a?(Hash)
|
|
13
|
+
|
|
14
|
+
values = [primary, *aliases].flat_map do |key|
|
|
15
|
+
[key.to_s, key.to_sym].uniq.filter_map { |actual| args[actual] if args.key?(actual) }
|
|
16
|
+
end
|
|
17
|
+
values.compact!
|
|
18
|
+
return [nil, nil] if values.empty?
|
|
19
|
+
return [nil, ToolOutput.error("#{label} must be a string.")] unless values.all?(String)
|
|
20
|
+
|
|
21
|
+
[values.first, nil]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.boolean_argument(args, key, label: key)
|
|
25
|
+
return [nil, ToolOutput.error("Tool arguments must be an object.")] unless args.is_a?(Hash)
|
|
26
|
+
|
|
27
|
+
values = [key.to_s, key.to_sym].uniq.filter_map { |actual| args[actual] if args.key?(actual) }
|
|
28
|
+
values.compact!
|
|
29
|
+
return [false, nil] if values.empty?
|
|
30
|
+
return [nil, ToolOutput.error("#{label} must be a boolean.")] unless values.all? { |value| [true, false].include?(value) }
|
|
31
|
+
|
|
32
|
+
[values.first, nil]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.warmup_output(graph)
|
|
36
|
+
return nil unless graph.respond_to?(:warmup_status)
|
|
37
|
+
|
|
38
|
+
status = graph.warmup_status
|
|
39
|
+
return nil unless status
|
|
40
|
+
|
|
41
|
+
state = status[:state] || status["state"]
|
|
42
|
+
if [:warming, "warming"].include?(state)
|
|
43
|
+
indexed = status[:indexed] || status["indexed"]
|
|
44
|
+
total = status[:total] || status["total"]
|
|
45
|
+
progress = if indexed.is_a?(Numeric) && total.is_a?(Numeric)
|
|
46
|
+
" (#{indexed}/#{total} files indexed)"
|
|
47
|
+
else
|
|
48
|
+
""
|
|
49
|
+
end
|
|
50
|
+
ToolOutput.error("AST index warming; retry this request shortly#{progress}.")
|
|
51
|
+
elsif [:failed, "failed"].include?(state)
|
|
52
|
+
ToolOutput.error("AST index warmup failed; retry after running '!context scan'.")
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.rank_matches(matches, query, graph)
|
|
57
|
+
symbol_map = graph.respond_to?(:symbols) ? graph.symbols : nil
|
|
58
|
+
qualified = symbol_map.respond_to?(:qualified_symbols) ? symbol_map.qualified_symbols : {}
|
|
59
|
+
keys_by_id = {}.compare_by_identity
|
|
60
|
+
qualified.sort_by { |key, _symbol| key }.each do |(key, symbol)|
|
|
61
|
+
keys_by_id[symbol] ||= key
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
matches.each_with_index.sort_by do |symbol, index|
|
|
65
|
+
name = symbol.name.to_s.downcase
|
|
66
|
+
lowered_query = query.downcase
|
|
67
|
+
rank = if name == lowered_query
|
|
68
|
+
0
|
|
69
|
+
elsif name.start_with?(lowered_query)
|
|
70
|
+
1
|
|
71
|
+
else
|
|
72
|
+
2
|
|
73
|
+
end
|
|
74
|
+
key = keys_by_id[symbol] || "#{symbol.file}##{symbol.name}"
|
|
75
|
+
[rank, key, index]
|
|
76
|
+
end.map(&:first)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.render_search(query, matches)
|
|
80
|
+
shown_count = [matches.length, 50].min
|
|
81
|
+
loop do
|
|
82
|
+
omitted = matches.length - shown_count
|
|
83
|
+
lines = ["Found #{matches.length} matching symbol(s) for '#{query}':"]
|
|
84
|
+
matches.first(shown_count).each do |sym|
|
|
85
|
+
lines << "• #{sym.kind.to_s.upcase} #{sym.name} (#{sym.file}:#{sym.line_start}-#{sym.line_end})"
|
|
86
|
+
lines << " Signature: #{sym.signature}"
|
|
87
|
+
lines << " Calls: #{sym.calls.join(', ')}"
|
|
88
|
+
end
|
|
89
|
+
lines << "... #{omitted} additional matches omitted." if omitted.positive?
|
|
90
|
+
|
|
91
|
+
truncated = Truncator.truncate(lines.join("\n"))
|
|
92
|
+
return truncated[:text] if !truncated[:truncated] || shown_count <= 1
|
|
93
|
+
|
|
94
|
+
shown_count -= 1
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.fuzzy_candidate_names(graph)
|
|
99
|
+
symbols = graph.respond_to?(:symbols) ? graph.symbols : nil
|
|
100
|
+
return [] unless symbols
|
|
101
|
+
|
|
102
|
+
names = if symbols.respond_to?(:values)
|
|
103
|
+
symbols.values.filter_map { |sym| sym.name if sym.respond_to?(:name) }.uniq
|
|
104
|
+
else
|
|
105
|
+
[]
|
|
106
|
+
end
|
|
107
|
+
return names unless names.empty?
|
|
108
|
+
|
|
109
|
+
symbols.respond_to?(:keys) ? symbols.keys : []
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "English"
|
|
4
|
-
|
|
5
3
|
module Rubik
|
|
6
4
|
module Tools
|
|
7
5
|
module Truncator
|
|
8
6
|
DEFAULT_MAX_CHARS = 12_000
|
|
9
7
|
DEFAULT_MAX_LINES = 300
|
|
10
8
|
|
|
11
|
-
def self.truncate(text, max_chars: DEFAULT_MAX_CHARS, max_lines: DEFAULT_MAX_LINES, strategy: :head_tail)
|
|
9
|
+
def self.truncate(text, max_chars: DEFAULT_MAX_CHARS, max_lines: DEFAULT_MAX_LINES, strategy: :head_tail, separator: "\n")
|
|
12
10
|
str = text.to_s
|
|
13
11
|
# byteslice truncation below cuts on byte boundaries and can split a
|
|
14
12
|
# multibyte character, producing invalid UTF-8 that then poisons the
|
|
@@ -16,15 +14,14 @@ module Rubik
|
|
|
16
14
|
# estimation). Scrub exactly once, after the last byteslice.
|
|
17
15
|
str = str.scrub("") unless str.valid_encoding?
|
|
18
16
|
|
|
19
|
-
max_c = (max_chars || DEFAULT_MAX_CHARS).to_i
|
|
20
|
-
max_l = (max_lines || DEFAULT_MAX_LINES).to_i
|
|
17
|
+
max_c = [(max_chars || DEFAULT_MAX_CHARS).to_i, 0].max
|
|
18
|
+
max_l = [(max_lines || DEFAULT_MAX_LINES).to_i, 0].max
|
|
19
|
+
sep = separator || "\n"
|
|
21
20
|
|
|
22
|
-
# PERF: With the default
|
|
21
|
+
# PERF: With the default newline separator, calculate the line upper bound
|
|
23
22
|
# using zero-allocation C-level #count("\n") and short-circuit BEFORE
|
|
24
|
-
# calling #lines (which allocates an Array of line strings).
|
|
25
|
-
|
|
26
|
-
# separator, since String#lines uses $/ when no separator is supplied.
|
|
27
|
-
if $INPUT_RECORD_SEPARATOR == "\n"
|
|
23
|
+
# calling #lines (which allocates an Array of line strings).
|
|
24
|
+
if sep == "\n"
|
|
28
25
|
line_bound = if str.empty?
|
|
29
26
|
0
|
|
30
27
|
elsif str.end_with?("\n")
|
|
@@ -35,7 +32,8 @@ module Rubik
|
|
|
35
32
|
return { text: str, truncated: false, omitted_lines: 0, omitted_chars: 0 } if str.bytesize <= max_c && line_bound <= max_l
|
|
36
33
|
end
|
|
37
34
|
|
|
38
|
-
lines = str.lines
|
|
35
|
+
lines = str.lines(sep)
|
|
36
|
+
return { text: str, truncated: false, omitted_lines: 0, omitted_chars: 0 } if str.bytesize <= max_c && lines.size <= max_l
|
|
39
37
|
|
|
40
38
|
strategy_sym = strategy.to_s.downcase.to_sym
|
|
41
39
|
|
|
@@ -44,7 +42,8 @@ module Rubik
|
|
|
44
42
|
kept_lines = lines.first(max_l)
|
|
45
43
|
kept_str = kept_lines.join
|
|
46
44
|
kept_str = kept_str.byteslice(0, max_c) if kept_str.bytesize > max_c
|
|
47
|
-
|
|
45
|
+
kept_line_count = kept_str.empty? ? 0 : kept_str.lines(sep).size
|
|
46
|
+
omitted_lines = [lines.size - kept_line_count, 0].max
|
|
48
47
|
omitted_chars = [str.bytesize - kept_str.bytesize, 0].max
|
|
49
48
|
notice = "\n... [Truncated: omitted #{omitted_lines} lines / #{omitted_chars} chars] ...\n"
|
|
50
49
|
{ text: finalize(kept_str, notice, prefix: true), truncated: true,
|
|
@@ -53,29 +52,63 @@ module Rubik
|
|
|
53
52
|
kept_lines = lines.last(max_l)
|
|
54
53
|
kept_str = kept_lines.join
|
|
55
54
|
kept_str = kept_str.byteslice(-max_c, max_c) if kept_str.bytesize > max_c
|
|
56
|
-
|
|
55
|
+
kept_line_count = kept_str.empty? ? 0 : kept_str.lines(sep).size
|
|
56
|
+
omitted_lines = [lines.size - kept_line_count, 0].max
|
|
57
57
|
omitted_chars = [str.bytesize - kept_str.bytesize, 0].max
|
|
58
58
|
notice = "\n... [Truncated: omitted #{omitted_lines} lines / #{omitted_chars} chars] ...\n"
|
|
59
59
|
{ text: finalize(kept_str, notice, prefix: false), truncated: true,
|
|
60
60
|
omitted_lines: omitted_lines, omitted_chars: omitted_chars }
|
|
61
61
|
else # :head_tail
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
if max_l <= 1
|
|
63
|
+
kept_lines = lines.first(max_l)
|
|
64
|
+
kept_str = kept_lines.join
|
|
65
|
+
kept_str = kept_str.byteslice(0, max_c) if kept_str.bytesize > max_c
|
|
66
|
+
kept_line_count = kept_str.empty? ? 0 : kept_str.lines(sep).size
|
|
67
|
+
omitted_lines = [lines.size - kept_line_count, 0].max
|
|
68
|
+
omitted_chars = [str.bytesize - kept_str.bytesize, 0].max
|
|
69
|
+
notice = "\n\n... [Truncated: #{omitted_lines} lines / #{omitted_chars} chars omitted to preserve token budget] ...\n\n"
|
|
70
|
+
return { text: finalize(kept_str, notice, prefix: true), truncated: true,
|
|
71
|
+
omitted_lines: omitted_lines, omitted_chars: omitted_chars }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
head_lines_count = max_l / 2
|
|
75
|
+
tail_lines_count = max_l - head_lines_count
|
|
76
|
+
|
|
77
|
+
head_chars = max_c / 2
|
|
78
|
+
tail_chars = max_c - head_chars
|
|
64
79
|
|
|
65
|
-
|
|
66
|
-
|
|
80
|
+
if lines.size == 1
|
|
81
|
+
head_lines = lines
|
|
82
|
+
tail_lines = lines
|
|
83
|
+
elsif head_lines_count + tail_lines_count >= lines.size
|
|
84
|
+
head_n = [head_lines_count, lines.size / 2].min
|
|
85
|
+
tail_n = [tail_lines_count, lines.size - head_n].min
|
|
86
|
+
head_lines = lines.first(head_n)
|
|
87
|
+
tail_lines = lines.last(tail_n)
|
|
88
|
+
else
|
|
89
|
+
head_lines = lines.first(head_lines_count)
|
|
90
|
+
tail_lines = lines.last(tail_lines_count)
|
|
91
|
+
end
|
|
67
92
|
|
|
68
93
|
head_str = head_lines.join
|
|
69
94
|
tail_str = tail_lines.join
|
|
70
95
|
|
|
71
|
-
head_str = head_str.byteslice(0,
|
|
72
|
-
tail_str = tail_str.byteslice(-
|
|
96
|
+
head_str = head_str.byteslice(0, head_chars) if head_str.bytesize > head_chars
|
|
97
|
+
tail_str = tail_str.byteslice(-tail_chars, tail_chars) if tail_str.bytesize > tail_chars
|
|
73
98
|
|
|
74
|
-
omitted_lines =
|
|
99
|
+
omitted_lines = if lines.size == 1
|
|
100
|
+
0
|
|
101
|
+
else
|
|
102
|
+
head_k = head_str.empty? ? 0 : head_str.lines(sep).size
|
|
103
|
+
tail_k = tail_str.empty? ? 0 : tail_str.lines(sep).size
|
|
104
|
+
[lines.size - (head_k + tail_k), 0].max
|
|
105
|
+
end
|
|
75
106
|
omitted_chars = [str.bytesize - (head_str.bytesize + tail_str.bytesize), 0].max
|
|
76
107
|
notice = "\n\n... [Truncated: #{omitted_lines} lines / #{omitted_chars} chars omitted to preserve token budget] ...\n\n"
|
|
77
108
|
|
|
78
|
-
|
|
109
|
+
head_final = head_str.valid_encoding? ? head_str : head_str.scrub("")
|
|
110
|
+
tail_final = tail_str.valid_encoding? ? tail_str : tail_str.scrub("")
|
|
111
|
+
{ text: "#{head_final}#{notice}#{tail_final}", truncated: true,
|
|
79
112
|
omitted_lines: omitted_lines, omitted_chars: omitted_chars }
|
|
80
113
|
end
|
|
81
114
|
end
|
data/lib/rubik/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rubik
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.3.
|
|
4
|
+
version: 0.3.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Özgür Ulukır
|
|
@@ -249,6 +249,7 @@ files:
|
|
|
249
249
|
- lib/rubik/ast/fallback_cleaner.rb
|
|
250
250
|
- lib/rubik/ast/go_extractor.rb
|
|
251
251
|
- lib/rubik/ast/graph.rb
|
|
252
|
+
- lib/rubik/ast/graph_extensions.rb
|
|
252
253
|
- lib/rubik/ast/python_extractor.rb
|
|
253
254
|
- lib/rubik/ast/ruby_extractor.rb
|
|
254
255
|
- lib/rubik/ast/rust_extractor.rb
|
|
@@ -757,6 +758,7 @@ files:
|
|
|
757
758
|
- lib/rubik/tools/registry.rb
|
|
758
759
|
- lib/rubik/tools/result.rb
|
|
759
760
|
- lib/rubik/tools/services.rb
|
|
761
|
+
- lib/rubik/tools/symbol_tool_support.rb
|
|
760
762
|
- lib/rubik/tools/truncator.rb
|
|
761
763
|
- lib/rubik/tools/uri_resolver.rb
|
|
762
764
|
- lib/rubik/ui.rb
|