rubydex 0.2.8 → 0.2.9

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.
data/exe/rdx CHANGED
@@ -7,7 +7,7 @@ require "optparse"
7
7
 
8
8
  options = {}
9
9
 
10
- OptionParser.new do |parser|
10
+ option_parser = OptionParser.new do |parser|
11
11
  parser.on("--version", "Print the gem's version") do
12
12
  require "rubydex/version"
13
13
  puts "v#{Rubydex::VERSION}"
@@ -22,10 +22,36 @@ OptionParser.new do |parser|
22
22
  parser.on("-i", "--interactive", "Open an interactive session with a populated graph for the current workspace") do
23
23
  options[:interactive] = true
24
24
  end
25
- end.parse!
25
+
26
+ parser.on("--mcp", "Run the MCP server for AI assistants") do
27
+ options[:mcp] = true
28
+ end
29
+ end
30
+
31
+ begin
32
+ arguments = option_parser.parse(ARGV)
33
+ rescue OptionParser::ParseError => e
34
+ warn("error: #{e.message}")
35
+ warn
36
+ warn(option_parser)
37
+ exit(2)
38
+ end
26
39
 
27
40
  require "rubydex"
28
41
 
42
+ if options[:mcp]
43
+ if arguments.length > 1
44
+ warn("error: unexpected argument '#{arguments[1]}' found")
45
+ warn
46
+ warn(option_parser)
47
+ exit(2)
48
+ end
49
+
50
+ require "rubydex/mcp_server"
51
+ Rubydex::MCPServer.run(arguments.fetch(0, Dir.pwd))
52
+ exit
53
+ end
54
+
29
55
  def __with_timer(message, &block)
30
56
  print(message)
31
57
  start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "mkmf"
4
- require "fileutils"
5
4
  require "pathname"
6
5
 
7
6
  unless system("cargo", "--version", out: File::NULL, err: File::NULL)
@@ -82,12 +81,6 @@ else
82
81
  end
83
82
 
84
83
  lib_dir = gem_dir.join("lib").join("rubydex")
85
- mcp_bin_dir = lib_dir.join("bin")
86
- FileUtils.mkdir_p(mcp_bin_dir)
87
-
88
- mcp_executable = Gem.win_platform? ? "rubydex_mcp.exe" : "rubydex_mcp"
89
- mcp_src = target_dir.join(mcp_executable)
90
- mcp_dst = mcp_bin_dir.join(mcp_executable)
91
84
 
92
85
  copy_dylib_commands = if Gem.win_platform?
93
86
  ""
@@ -114,7 +107,6 @@ new_makefile = makefile.gsub("$(OBJS): $(HDRS) $(ruby_headers)", <<~MAKEFILE.cho
114
107
  .rust_built: $(RUST_SRCS)
115
108
  \t#{cargo_command} || (echo "Compiling Rust failed" && exit 1)
116
109
  \t$(COPY) #{bindings_path} #{__dir__}
117
- \t$(COPY) #{mcp_src} #{mcp_dst}
118
110
  \ttouch $@
119
111
 
120
112
  compile_rust: .rust_built
data/ext/rubydex/graph.c CHANGED
@@ -796,38 +796,38 @@ static VALUE rdxr_graph_complete_method_argument(int argc, VALUE *argv, VALUE se
796
796
 
797
797
  /*
798
798
  * call-seq:
799
- * exclude_paths(paths) -> nil
799
+ * exclude_patterns(patterns) -> nil
800
800
  *
801
- * Excludes the paths from file discovery during indexing.
801
+ * Excludes files matching the given glob patterns from discovery during indexing.
802
802
  */
803
- static VALUE rdxr_graph_exclude_paths(VALUE self, VALUE paths) {
804
- Check_Type(paths, T_ARRAY);
805
- rdxi_check_array_of_strings(paths);
803
+ static VALUE rdxr_graph_exclude_patterns(VALUE self, VALUE patterns) {
804
+ Check_Type(patterns, T_ARRAY);
805
+ rdxi_check_array_of_strings(patterns);
806
806
 
807
- size_t length = RARRAY_LEN(paths);
808
- char **converted_paths = rdxi_str_array_to_char(paths, length);
807
+ size_t length = RARRAY_LEN(patterns);
808
+ char **converted_patterns = rdxi_str_array_to_char(patterns, length);
809
809
 
810
810
  void *graph;
811
811
  TypedData_Get_Struct(self, void*, &graph_type, graph);
812
812
 
813
- rdx_graph_exclude_paths(graph, (const char **)converted_paths, length);
814
- rdxi_free_str_array(converted_paths, length);
813
+ rdx_graph_exclude_patterns(graph, (const char **)converted_patterns, length);
814
+ rdxi_free_str_array(converted_patterns, length);
815
815
 
816
816
  return Qnil;
817
817
  }
818
818
 
819
819
  /*
820
820
  * call-seq:
821
- * excluded_paths -> Array[String]
821
+ * excluded_patterns -> Array[String]
822
822
  *
823
- * Returns the paths currently excluded from file discovery.
823
+ * Returns the glob patterns currently excluded from file discovery.
824
824
  */
825
- static VALUE rdxr_graph_excluded_paths(VALUE self) {
825
+ static VALUE rdxr_graph_excluded_patterns(VALUE self) {
826
826
  void *graph;
827
827
  TypedData_Get_Struct(self, void*, &graph_type, graph);
828
828
 
829
829
  size_t out_count = 0;
830
- const char *const *results = rdx_graph_excluded_paths(graph, &out_count);
830
+ const char *const *results = rdx_graph_excluded_patterns(graph, &out_count);
831
831
 
832
832
  if (results == NULL) {
833
833
  return rb_ary_new();
@@ -965,8 +965,8 @@ void rdxi_initialize_graph(VALUE moduleRubydex) {
965
965
  rb_define_method(cGraph, "complete_namespace_access", rdxr_graph_complete_namespace_access, -1);
966
966
  rb_define_method(cGraph, "complete_method_call", rdxr_graph_complete_method_call, -1);
967
967
  rb_define_method(cGraph, "complete_method_argument", rdxr_graph_complete_method_argument, -1);
968
- rb_define_method(cGraph, "exclude_paths", rdxr_graph_exclude_paths, 1);
969
- rb_define_method(cGraph, "excluded_paths", rdxr_graph_excluded_paths, 0);
968
+ rb_define_method(cGraph, "exclude_patterns", rdxr_graph_exclude_patterns, 1);
969
+ rb_define_method(cGraph, "excluded_patterns", rdxr_graph_excluded_patterns, 0);
970
970
  rb_define_method(cGraph, "workspace_path", rdxr_graph_workspace_path, 0);
971
971
  rb_define_method(cGraph, "workspace_path=", rdxr_graph_set_workspace_path, 1);
972
972
  rb_define_method(cGraph, "load_config", rdxr_graph_load_config, -1);
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Rubydex
6
+ module MCPServer
7
+ module JSONRPC
8
+ PARSE_ERROR = -32_700
9
+ INVALID_REQUEST = -32_600
10
+ METHOD_NOT_FOUND = -32_601
11
+ INVALID_PARAMS = -32_602
12
+ INTERNAL_ERROR = -32_603
13
+
14
+ class << self
15
+ #: (untyped, Integer, String, ?data: untyped) -> Hash
16
+ def error_response(id, code, message, data: nil)
17
+ {
18
+ jsonrpc: "2.0",
19
+ id: id,
20
+ error: {
21
+ code: code,
22
+ message: message,
23
+ data: data,
24
+ }.compact,
25
+ }
26
+ end
27
+ end
28
+ end
29
+
30
+ class Error
31
+ #: (String, ?String, ?String) -> void
32
+ def initialize(error, message = nil, suggestion = nil)
33
+ @error = error
34
+ @message = message
35
+ @suggestion = suggestion
36
+ end
37
+
38
+ #: (*untyped) -> String
39
+ def to_json(*args)
40
+ payload = { error: @error }
41
+ payload[:message] = @message if @message
42
+ payload[:suggestion] = @suggestion if @suggestion
43
+ payload.to_json(*args)
44
+ end
45
+ end
46
+
47
+ class Tool
48
+ @tools = []
49
+
50
+ class Response
51
+ #: (Array[Hash], ?bool) -> void
52
+ def initialize(content, error: false)
53
+ @content = content
54
+ @error = error
55
+ end
56
+
57
+ attr_reader :content
58
+
59
+ #: -> bool
60
+ def error?
61
+ @error
62
+ end
63
+
64
+ #: -> Hash
65
+ def to_h
66
+ {
67
+ content: content,
68
+ isError: error?,
69
+ }
70
+ end
71
+ end
72
+
73
+ class << self
74
+ attr_reader :tools
75
+
76
+ #: (Class) -> void
77
+ def inherited(tool)
78
+ Tool.tools << tool unless tool.name&.end_with?("::BaseTool")
79
+ super
80
+ end
81
+
82
+ #: -> Hash[String, Class]
83
+ def tools_by_name
84
+ tools.to_h { |tool| [tool.tool_name, tool] }
85
+ end
86
+
87
+ #: (?String) -> String
88
+ def tool_name(value = nil)
89
+ @tool_name = value if value
90
+ @tool_name || raise(NotImplementedError, "#{name} must define tool_name")
91
+ end
92
+
93
+ #: (?String) -> String
94
+ def description(value = nil)
95
+ @description = value if value
96
+ @description || raise(NotImplementedError, "#{name} must define description")
97
+ end
98
+
99
+ #: (?Hash, ?Array[String]) -> Hash
100
+ def input_schema(properties: nil, required: nil)
101
+ if properties
102
+ @input_schema = {
103
+ type: "object",
104
+ properties: properties,
105
+ }
106
+ @input_schema[:required] = required if required
107
+ end
108
+
109
+ @input_schema || { type: "object", properties: {} }
110
+ end
111
+
112
+ #: -> Hash
113
+ def to_h
114
+ {
115
+ name: tool_name,
116
+ description: description,
117
+ inputSchema: input_schema,
118
+ }
119
+ end
120
+ end
121
+ end
122
+
123
+ class StdioTransport
124
+ #: -> void
125
+ def initialize
126
+ @input = $stdin
127
+ @output = $stdout
128
+ @input.binmode
129
+ @input.sync = true
130
+ @output.binmode
131
+ @output.sync = true
132
+ end
133
+
134
+ #: { (Hash | Array?, Hash?) -> void } -> void
135
+ def open
136
+ @input.each_line do |line|
137
+ yield JSON.parse(line, symbolize_names: true)
138
+ rescue JSON::ParserError
139
+ yield nil, JSONRPC.error_response(nil, JSONRPC::PARSE_ERROR, "Parse error", data: "Invalid JSON")
140
+ end
141
+ end
142
+
143
+ #: (Hash | Array[Hash]?) -> void
144
+ def write(response)
145
+ return unless response
146
+
147
+ @output.puts(JSON.generate(response))
148
+ end
149
+
150
+ #: -> void
151
+ def close
152
+ @output.flush
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "pathname"
5
+ require "uri"
6
+
7
+ module Rubydex
8
+ module MCPServer
9
+ class BaseTool < Tool
10
+ DEFAULT_LIMIT = 50
11
+
12
+ #: (Graph) -> void
13
+ def initialize(graph)
14
+ super()
15
+ @graph = graph
16
+ @root_path = graph.workspace_path
17
+ end
18
+
19
+ #: (Hash | Error) -> Tool::Response
20
+ def response(payload)
21
+ Tool::Response.new([{ type: "text", text: JSON.generate(payload) }])
22
+ end
23
+
24
+ #: (Declaration) -> String
25
+ def declaration_kind(declaration)
26
+ return "<TODO>" if declaration.is_a?(Rubydex::Todo)
27
+
28
+ declaration.class.name.delete_prefix("Rubydex::")
29
+ end
30
+
31
+ #: (String) -> String
32
+ def format_path(uri)
33
+ path = file_path_for_uri(uri)
34
+ return uri unless path
35
+
36
+ absolute_path = File.expand_path(path)
37
+ absolute_root = File.expand_path(@root_path)
38
+ relative_path = Pathname.new(absolute_path).relative_path_from(Pathname.new(absolute_root)).to_s
39
+
40
+ relative_path.start_with?("..") ? absolute_path : relative_path
41
+ end
42
+
43
+ #: (String) -> String?
44
+ def file_path_for_uri(uri)
45
+ parsed = URI.parse(uri)
46
+ return unless parsed.scheme == "file"
47
+
48
+ path = URI.decode_uri_component(parsed.path)
49
+ path.delete_prefix!("/") if Gem.win_platform?
50
+ path
51
+ rescue URI::InvalidURIError, ArgumentError
52
+ nil
53
+ end
54
+
55
+ #: (String) -> Document?
56
+ def document_for_path(file_path)
57
+ absolute_target = if Pathname.new(file_path).absolute?
58
+ file_path
59
+ else
60
+ File.join(@root_path, file_path)
61
+ end
62
+ canonical_target = File.realpath(absolute_target)
63
+ @graph.documents.find do |document|
64
+ path = file_path_for_uri(document.uri)
65
+ path && File.expand_path(path) == canonical_target
66
+ end
67
+ end
68
+
69
+ #: (Location) -> Hash
70
+ def display_location(location)
71
+ display = location.to_display
72
+ {
73
+ path: format_path(display.uri),
74
+ line: display.start_line,
75
+ }
76
+ end
77
+
78
+ #: (Enumerable, Integer?, Integer?, Integer) -> [Array, Integer]
79
+ def paginate(items, offset, limit, max_limit)
80
+ offset = (offset || 0).to_i
81
+ offset = 0 unless offset.positive?
82
+ limit = (limit || DEFAULT_LIMIT).to_i
83
+ limit = DEFAULT_LIMIT unless limit.positive?
84
+ limit = [limit, max_limit].min
85
+
86
+ page = []
87
+ index = 0
88
+ items.each do |item|
89
+ page << item if index >= offset && page.length < limit
90
+ index += 1
91
+ end
92
+
93
+ [page, index]
94
+ end
95
+
96
+ #: (String) -> Declaration | Error
97
+ def lookup_declaration(name)
98
+ declaration = @graph[name]
99
+ return declaration if declaration
100
+
101
+ Error.new(
102
+ "not_found",
103
+ "Declaration '#{name}' not found",
104
+ "Try search_declarations with a partial name to find the correct FQN",
105
+ )
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubydex
4
+ module MCPServer
5
+ class CodebaseStatsTool < BaseTool
6
+ tool_name "codebase_stats"
7
+ description "Get an overview of the indexed Ruby codebase: total file count, declaration counts, and breakdown by kind (classes, modules, methods, constants). Use this to understand codebase size and composition, or to verify that indexing completed successfully."
8
+ input_schema(properties: {})
9
+
10
+ #: -> Tool::Response
11
+ def call
12
+ declaration_count = 0
13
+ breakdown = Hash.new(0)
14
+ @graph.declarations.each do |declaration|
15
+ declaration_count += 1
16
+ breakdown[declaration_kind(declaration)] += 1
17
+ end
18
+
19
+ response(
20
+ files: @graph.documents.count,
21
+ declarations: declaration_count,
22
+ definitions: @graph.documents.sum { |document| document.definitions.count },
23
+ constant_references: @graph.constant_references.count,
24
+ method_references: @graph.method_references.count,
25
+ breakdown_by_kind: breakdown,
26
+ )
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubydex
4
+ module MCPServer
5
+ class FindConstantReferencesTool < BaseTool
6
+ tool_name "find_constant_references"
7
+ description "Find all resolved references to a Ruby class, module, or constant across the codebase. Returns file paths, line numbers, and columns for each usage. Results are paginated: the response includes `total`. If `total` exceeds the number of returned results, use `offset` to fetch subsequent pages."
8
+ input_schema(
9
+ properties: {
10
+ name: { type: "string", description: "Fully qualified name of the class, module, or constant to find references for" },
11
+ limit: { type: "integer", description: "Maximum number of references to return (default 50, max 200)" },
12
+ offset: { type: "integer", description: "Number of references to skip for pagination (default 0)" },
13
+ },
14
+ required: ["name"],
15
+ )
16
+
17
+ #: (name: String, ?limit: Integer, ?offset: Integer) -> Tool::Response
18
+ def call(name:, limit: nil, offset: nil)
19
+ declaration = lookup_declaration(name)
20
+
21
+ case declaration
22
+ when Error
23
+ response(declaration)
24
+ else
25
+ references = case declaration
26
+ when Rubydex::Namespace, Rubydex::Constant, Rubydex::ConstantAlias
27
+ declaration.references
28
+ else
29
+ []
30
+ end
31
+ page, total = paginate(references, offset, limit, 200)
32
+ payload = page.map do |reference|
33
+ display = reference.location.to_display
34
+ {
35
+ path: format_path(display.uri),
36
+ line: display.start_line,
37
+ column: display.start_column,
38
+ }
39
+ end
40
+
41
+ response(name: name, references: payload, total: total)
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubydex
4
+ module MCPServer
5
+ class GetDeclarationTool < BaseTool
6
+ tool_name "get_declaration"
7
+ description 'Get complete information about a Ruby class, module, method, or constant by its exact fully qualified name. Returns file locations, documentation comments, ancestor chain, and members with locations. FQN format: "Foo::Bar" for classes/modules/constants, "Foo::Bar#method_name" for instance methods, "Foo::Bar::<Bar>" for singleton classes, and "Foo::Bar::<Bar>#method_name" for class methods.'
8
+ input_schema(
9
+ properties: {
10
+ name: { type: "string", description: "Fully qualified name of the declaration (e.g. 'Foo::Bar', 'Foo::Bar#baz')" },
11
+ },
12
+ required: ["name"],
13
+ )
14
+
15
+ #: (name: String) -> Tool::Response
16
+ def call(name:)
17
+ declaration = lookup_declaration(name)
18
+
19
+ case declaration
20
+ when Error
21
+ response(declaration)
22
+ else
23
+ definitions = declaration.definitions.map do |definition|
24
+ display_location(definition.location).merge(
25
+ comments: definition.comments.map do |comment|
26
+ comment.string.delete_prefix("# ")
27
+ end,
28
+ )
29
+ end
30
+
31
+ ancestors = if declaration.is_a?(Rubydex::Namespace)
32
+ declaration.ancestors.map do |ancestor|
33
+ {
34
+ name: ancestor.name,
35
+ kind: declaration_kind(ancestor),
36
+ }
37
+ end
38
+ else
39
+ []
40
+ end
41
+
42
+ members = if declaration.is_a?(Rubydex::Namespace)
43
+ declaration.members.map do |member|
44
+ payload = {
45
+ name: member.name,
46
+ kind: declaration_kind(member),
47
+ }
48
+
49
+ definition = member.definitions.first
50
+ payload[:location] = display_location(definition.location) if definition
51
+ payload
52
+ end
53
+ else
54
+ []
55
+ end
56
+
57
+ response(
58
+ name: declaration.name,
59
+ kind: declaration_kind(declaration),
60
+ definitions: definitions,
61
+ ancestors: ancestors,
62
+ members: members,
63
+ )
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubydex
4
+ module MCPServer
5
+ class GetDescendantsTool < BaseTool
6
+ tool_name "get_descendants"
7
+ description "Returns all known descendants for the given namespace including itself and all transitive descendants. Can be used to understand how a module/class is used across the codebase. Results are paginated: the response includes `total`. If `total` exceeds the number of returned results, use `offset` to fetch subsequent pages."
8
+ input_schema(
9
+ properties: {
10
+ name: { type: "string", description: "Fully qualified name of the class or module" },
11
+ limit: { type: "integer", description: "Maximum number of descendants to return (default 50, max 500)" },
12
+ offset: { type: "integer", description: "Number of descendants to skip for pagination (default 0)" },
13
+ },
14
+ required: ["name"],
15
+ )
16
+
17
+ #: (name: String, ?limit: Integer, ?offset: Integer) -> Tool::Response
18
+ def call(name:, limit: nil, offset: nil)
19
+ declaration = lookup_declaration(name)
20
+
21
+ case declaration
22
+ when Error
23
+ response(declaration)
24
+ when Rubydex::Namespace
25
+ page, total = paginate(declaration.descendants, offset, limit, 500)
26
+ descendants = page.map do |descendant|
27
+ {
28
+ name: descendant.name,
29
+ kind: declaration_kind(descendant),
30
+ }
31
+ end
32
+
33
+ response(name: declaration.name, descendants: descendants, total: total)
34
+ else
35
+ response(
36
+ Error.new(
37
+ "invalid_kind",
38
+ "'#{name}' is not a class or module (it is a #{declaration_kind(declaration)})",
39
+ "get_descendants only works on classes and modules, not methods or constants",
40
+ ),
41
+ )
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubydex
4
+ module MCPServer
5
+ class GetFileDeclarationsTool < BaseTool
6
+ tool_name "get_file_declarations"
7
+ description "List all Ruby classes, modules, methods, and constants defined in a specific file. Returns a structural overview with names, kinds, and line numbers. Use this to understand a file's structure before reading it, or to see what a file contributes to the codebase. Accepts relative or absolute paths."
8
+ input_schema(
9
+ properties: {
10
+ file_path: { type: "string", description: "File path (relative or absolute) to list declarations for" },
11
+ },
12
+ required: ["file_path"],
13
+ )
14
+
15
+ #: (file_path: String) -> Tool::Response
16
+ def call(file_path:)
17
+ absolute_target = if Pathname.new(file_path).absolute?
18
+ file_path
19
+ else
20
+ File.join(@root_path, file_path)
21
+ end
22
+ return file_not_found_response(file_path) unless File.exist?(absolute_target)
23
+
24
+ document = document_for_path(file_path)
25
+ return file_not_found_response(file_path) unless document
26
+
27
+ declarations = document.definitions.filter_map do |definition|
28
+ declaration = definition.declaration
29
+ next unless declaration
30
+
31
+ {
32
+ name: declaration.name,
33
+ kind: declaration_kind(declaration),
34
+ line: definition.location.to_display.start_line,
35
+ }
36
+ end
37
+
38
+ response(file: format_path(document.uri), declarations: declarations)
39
+ end
40
+
41
+ private
42
+
43
+ #: (String) -> Tool::Response
44
+ def file_not_found_response(file_path)
45
+ response(
46
+ Error.new(
47
+ "not_found",
48
+ "File '#{file_path}' not found in the index",
49
+ "Use a relative path like 'app/models/user.rb' or an absolute path matching the indexed project",
50
+ ),
51
+ )
52
+ end
53
+ end
54
+ end
55
+ end