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.
@@ -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 input separator, calculate the line upper bound
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). Keep the
25
- # original #lines behavior when callers customize Ruby's global record
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
- omitted_lines = [lines.size - kept_lines.size, 0].max
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
- omitted_lines = [lines.size - kept_lines.size, 0].max
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
- half_lines = (max_l / 2).clamp(1, 150)
63
- half_chars = (max_c / 2).clamp(1, 6000)
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
- head_lines = lines.first(half_lines)
66
- tail_lines = lines.last(half_lines)
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, half_chars) if head_str.bytesize > half_chars
72
- tail_str = tail_str.byteslice(-half_chars, half_chars) if tail_str.bytesize > half_chars
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 = [lines.size - (head_lines.size + tail_lines.size), 0].max
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
- { text: finalize(tail_str, finalize(head_str, notice, prefix: true), prefix: false), truncated: true,
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rubik
4
- VERSION = "0.3.0"
4
+ VERSION = "0.3.2"
5
5
  end
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.0
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