rubydex 0.1.0.beta12-aarch64-linux

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.
Files changed (109) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +23 -0
  3. data/README.md +125 -0
  4. data/THIRD_PARTY_LICENSES.html +4562 -0
  5. data/exe/rdx +47 -0
  6. data/ext/rubydex/declaration.c +453 -0
  7. data/ext/rubydex/declaration.h +23 -0
  8. data/ext/rubydex/definition.c +284 -0
  9. data/ext/rubydex/definition.h +28 -0
  10. data/ext/rubydex/diagnostic.c +6 -0
  11. data/ext/rubydex/diagnostic.h +11 -0
  12. data/ext/rubydex/document.c +97 -0
  13. data/ext/rubydex/document.h +10 -0
  14. data/ext/rubydex/extconf.rb +138 -0
  15. data/ext/rubydex/graph.c +681 -0
  16. data/ext/rubydex/graph.h +10 -0
  17. data/ext/rubydex/handle.h +44 -0
  18. data/ext/rubydex/location.c +22 -0
  19. data/ext/rubydex/location.h +15 -0
  20. data/ext/rubydex/reference.c +123 -0
  21. data/ext/rubydex/reference.h +15 -0
  22. data/ext/rubydex/rubydex.c +22 -0
  23. data/ext/rubydex/utils.c +108 -0
  24. data/ext/rubydex/utils.h +34 -0
  25. data/lib/rubydex/3.2/rubydex.so +0 -0
  26. data/lib/rubydex/3.3/rubydex.so +0 -0
  27. data/lib/rubydex/3.4/rubydex.so +0 -0
  28. data/lib/rubydex/4.0/rubydex.so +0 -0
  29. data/lib/rubydex/comment.rb +17 -0
  30. data/lib/rubydex/diagnostic.rb +21 -0
  31. data/lib/rubydex/failures.rb +15 -0
  32. data/lib/rubydex/graph.rb +98 -0
  33. data/lib/rubydex/keyword.rb +17 -0
  34. data/lib/rubydex/keyword_parameter.rb +13 -0
  35. data/lib/rubydex/librubydex_sys.so +0 -0
  36. data/lib/rubydex/location.rb +90 -0
  37. data/lib/rubydex/mixin.rb +22 -0
  38. data/lib/rubydex/version.rb +5 -0
  39. data/lib/rubydex.rb +23 -0
  40. data/rbi/rubydex.rbi +422 -0
  41. data/rust/Cargo.lock +1851 -0
  42. data/rust/Cargo.toml +29 -0
  43. data/rust/about.hbs +78 -0
  44. data/rust/about.toml +10 -0
  45. data/rust/rubydex/Cargo.toml +42 -0
  46. data/rust/rubydex/src/compile_assertions.rs +13 -0
  47. data/rust/rubydex/src/diagnostic.rs +110 -0
  48. data/rust/rubydex/src/errors.rs +28 -0
  49. data/rust/rubydex/src/indexing/local_graph.rs +224 -0
  50. data/rust/rubydex/src/indexing/rbs_indexer.rs +1551 -0
  51. data/rust/rubydex/src/indexing/ruby_indexer.rs +2329 -0
  52. data/rust/rubydex/src/indexing/ruby_indexer_tests.rs +4962 -0
  53. data/rust/rubydex/src/indexing.rs +210 -0
  54. data/rust/rubydex/src/integrity.rs +279 -0
  55. data/rust/rubydex/src/job_queue.rs +205 -0
  56. data/rust/rubydex/src/lib.rs +17 -0
  57. data/rust/rubydex/src/listing.rs +371 -0
  58. data/rust/rubydex/src/main.rs +160 -0
  59. data/rust/rubydex/src/model/built_in.rs +83 -0
  60. data/rust/rubydex/src/model/comment.rs +24 -0
  61. data/rust/rubydex/src/model/declaration.rs +671 -0
  62. data/rust/rubydex/src/model/definitions.rs +1682 -0
  63. data/rust/rubydex/src/model/document.rs +222 -0
  64. data/rust/rubydex/src/model/encoding.rs +22 -0
  65. data/rust/rubydex/src/model/graph.rs +3754 -0
  66. data/rust/rubydex/src/model/id.rs +110 -0
  67. data/rust/rubydex/src/model/identity_maps.rs +58 -0
  68. data/rust/rubydex/src/model/ids.rs +60 -0
  69. data/rust/rubydex/src/model/keywords.rs +256 -0
  70. data/rust/rubydex/src/model/name.rs +298 -0
  71. data/rust/rubydex/src/model/references.rs +111 -0
  72. data/rust/rubydex/src/model/string_ref.rs +50 -0
  73. data/rust/rubydex/src/model/visibility.rs +41 -0
  74. data/rust/rubydex/src/model.rs +15 -0
  75. data/rust/rubydex/src/offset.rs +147 -0
  76. data/rust/rubydex/src/position.rs +6 -0
  77. data/rust/rubydex/src/query.rs +1841 -0
  78. data/rust/rubydex/src/resolution.rs +6517 -0
  79. data/rust/rubydex/src/stats/memory.rs +71 -0
  80. data/rust/rubydex/src/stats/orphan_report.rs +264 -0
  81. data/rust/rubydex/src/stats/timer.rs +127 -0
  82. data/rust/rubydex/src/stats.rs +11 -0
  83. data/rust/rubydex/src/test_utils/context.rs +226 -0
  84. data/rust/rubydex/src/test_utils/graph_test.rs +730 -0
  85. data/rust/rubydex/src/test_utils/local_graph_test.rs +602 -0
  86. data/rust/rubydex/src/test_utils.rs +52 -0
  87. data/rust/rubydex/src/visualization/dot.rs +192 -0
  88. data/rust/rubydex/src/visualization.rs +6 -0
  89. data/rust/rubydex/tests/cli.rs +185 -0
  90. data/rust/rubydex-mcp/Cargo.toml +28 -0
  91. data/rust/rubydex-mcp/src/main.rs +48 -0
  92. data/rust/rubydex-mcp/src/server.rs +1145 -0
  93. data/rust/rubydex-mcp/src/tools.rs +49 -0
  94. data/rust/rubydex-mcp/tests/mcp.rs +302 -0
  95. data/rust/rubydex-sys/Cargo.toml +20 -0
  96. data/rust/rubydex-sys/build.rs +14 -0
  97. data/rust/rubydex-sys/cbindgen.toml +12 -0
  98. data/rust/rubydex-sys/src/declaration_api.rs +485 -0
  99. data/rust/rubydex-sys/src/definition_api.rs +443 -0
  100. data/rust/rubydex-sys/src/diagnostic_api.rs +99 -0
  101. data/rust/rubydex-sys/src/document_api.rs +85 -0
  102. data/rust/rubydex-sys/src/graph_api.rs +948 -0
  103. data/rust/rubydex-sys/src/lib.rs +79 -0
  104. data/rust/rubydex-sys/src/location_api.rs +79 -0
  105. data/rust/rubydex-sys/src/name_api.rs +135 -0
  106. data/rust/rubydex-sys/src/reference_api.rs +267 -0
  107. data/rust/rubydex-sys/src/utils.rs +70 -0
  108. data/rust/rustfmt.toml +2 -0
  109. metadata +159 -0
@@ -0,0 +1,1145 @@
1
+ use std::collections::{HashMap, HashSet};
2
+ use std::path::{Path, PathBuf};
3
+ use std::sync::{Arc, RwLock};
4
+
5
+ use crate::tools::{
6
+ FindConstantReferencesParams, GetDeclarationParams, GetDescendantsParams, GetFileDeclarationsParams,
7
+ SearchDeclarationsParams,
8
+ };
9
+ use rmcp::{
10
+ ServerHandler,
11
+ handler::server::{router::tool::ToolRouter, wrapper::Parameters},
12
+ model::{ServerCapabilities, ServerInfo},
13
+ tool, tool_handler, tool_router,
14
+ transport::io::stdio,
15
+ };
16
+ use rubydex::model::ids::{DeclarationId, UriId};
17
+ use rubydex::model::{
18
+ declaration::{Ancestor, Ancestors},
19
+ graph::Graph,
20
+ };
21
+ use url::Url;
22
+
23
+ struct ServerState {
24
+ graph: Option<Graph>,
25
+ error: Option<String>,
26
+ }
27
+
28
+ pub struct RubydexServer {
29
+ state: Arc<RwLock<ServerState>>,
30
+ root_path: PathBuf,
31
+ tool_router: ToolRouter<Self>,
32
+ }
33
+
34
+ impl RubydexServer {
35
+ pub fn new(root: String) -> Self {
36
+ Self {
37
+ state: Arc::new(RwLock::new(ServerState {
38
+ graph: None,
39
+ error: None,
40
+ })),
41
+ root_path: PathBuf::from(root),
42
+ tool_router: Self::tool_router(),
43
+ }
44
+ }
45
+
46
+ /// Spawns a background thread that indexes the codebase and marks the server as ready.
47
+ pub fn spawn_indexer(&self, path: String) {
48
+ let state = Arc::clone(&self.state);
49
+ std::thread::spawn(move || {
50
+ let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
51
+ let (file_paths, errors) = rubydex::listing::collect_file_paths(vec![path], &HashSet::new());
52
+ for error in &errors {
53
+ eprintln!("Listing error: {error}");
54
+ }
55
+
56
+ let mut graph = Graph::new();
57
+ let errors = rubydex::indexing::index_files(&mut graph, file_paths);
58
+ for error in &errors {
59
+ eprintln!("Indexing error: {error}");
60
+ }
61
+
62
+ let mut resolver = rubydex::resolution::Resolver::new(&mut graph);
63
+ resolver.resolve();
64
+
65
+ eprintln!(
66
+ "Rubydex indexed {} files, {} declarations",
67
+ graph.documents().len(),
68
+ graph.declarations().len()
69
+ );
70
+ graph
71
+ }));
72
+
73
+ let mut state = state.write().expect("state lock poisoned");
74
+ match result {
75
+ Ok(graph) => {
76
+ state.graph = Some(graph);
77
+ }
78
+ Err(panic) => {
79
+ let msg = panic
80
+ .downcast_ref::<String>()
81
+ .map(String::as_str)
82
+ .or_else(|| panic.downcast_ref::<&str>().copied())
83
+ .unwrap_or("unknown error");
84
+ eprintln!("Rubydex indexing failed: {msg}");
85
+ state.error = Some(msg.to_string());
86
+ }
87
+ }
88
+ });
89
+ }
90
+
91
+ pub async fn serve(self) -> Result<(), Box<dyn std::error::Error>> {
92
+ let service = rmcp::ServiceExt::serve(self, stdio()).await?;
93
+ service.waiting().await?;
94
+ Ok(())
95
+ }
96
+ }
97
+
98
+ /// Returns a structured JSON error string with a machine-readable type, message, and suggestion.
99
+ fn error_json(error_type: &str, message: &str, suggestion: &str) -> String {
100
+ serde_json::to_string(&serde_json::json!({
101
+ "error": error_type,
102
+ "message": message,
103
+ "suggestion": suggestion,
104
+ }))
105
+ .unwrap_or_else(|_| "{}".to_string())
106
+ }
107
+
108
+ /// Acquires the read lock and returns a guard with the graph if ready.
109
+ /// Returns early with a JSON error if still indexing or if indexing failed.
110
+ macro_rules! ensure_graph_ready {
111
+ ($self:expr) => {{
112
+ let state = $self.state.read().expect("state lock poisoned");
113
+ if let Some(err) = &state.error {
114
+ return error_json(
115
+ "indexing_failed",
116
+ &format!("Rubydex indexing failed: {err}"),
117
+ "Check server logs for details. The MCP server needs to be restarted.",
118
+ );
119
+ }
120
+ if state.graph.is_none() {
121
+ return error_json(
122
+ "indexing",
123
+ "Rubydex is still indexing the codebase",
124
+ "The server is starting up. Please retry in a few seconds.",
125
+ );
126
+ }
127
+ state
128
+ }};
129
+ }
130
+
131
+ /// Looks up a declaration by name, returning an error JSON string from the caller if not found.
132
+ macro_rules! lookup_declaration {
133
+ ($graph:expr, $name:expr) => {{
134
+ let declaration_id = DeclarationId::from($name);
135
+ match $graph.declarations().get(&declaration_id) {
136
+ Some(decl) => (declaration_id, decl),
137
+ None => {
138
+ return error_json(
139
+ "not_found",
140
+ &format!("Declaration '{}' not found", $name),
141
+ "Try search_declarations with a partial name to find the correct FQN",
142
+ );
143
+ }
144
+ }
145
+ }};
146
+ }
147
+
148
+ /// Narrows a declaration to a namespace, returning an error JSON string if it's not a class or module.
149
+ macro_rules! require_namespace {
150
+ ($decl:expr, $name:expr, $tool_name:literal) => {
151
+ match $decl.as_namespace() {
152
+ Some(ns) => ns,
153
+ None => {
154
+ return error_json(
155
+ "invalid_kind",
156
+ &format!("'{}' is not a class or module (it is a {})", $name, $decl.kind()),
157
+ concat!(
158
+ $tool_name,
159
+ " only works on classes and modules, not methods or constants"
160
+ ),
161
+ );
162
+ }
163
+ }
164
+ };
165
+ }
166
+
167
+ /// Parses a file URI into a platform-native absolute path.
168
+ fn uri_to_path(uri: &str) -> Option<PathBuf> {
169
+ Url::parse(uri).ok()?.to_file_path().ok()
170
+ }
171
+
172
+ /// Converts a file URI to a path relative to `root` when possible.
173
+ /// Falls back to an absolute display path if it cannot be relativized.
174
+ fn format_path(uri: &str, root: &Path) -> String {
175
+ let Some(path) = uri_to_path(uri) else {
176
+ return uri.to_string();
177
+ };
178
+
179
+ path.strip_prefix(root)
180
+ .map_or_else(|_| path.display().to_string(), |rel| rel.display().to_string())
181
+ }
182
+
183
+ /// Formats an ancestor chain into a JSON array of `{"name": ..., "kind": ...}` objects.
184
+ fn format_ancestors(graph: &Graph, ancestors: &Ancestors) -> Vec<serde_json::Value> {
185
+ ancestors
186
+ .iter()
187
+ .filter_map(|ancestor| match ancestor {
188
+ Ancestor::Complete(id) => {
189
+ let ancestor_decl = graph.declarations().get(id)?;
190
+ Some(serde_json::json!({
191
+ "name": ancestor_decl.name(),
192
+ "kind": ancestor_decl.kind(),
193
+ }))
194
+ }
195
+ Ancestor::Partial(name_id) => {
196
+ let name_ref = graph.names().get(name_id)?;
197
+ Some(serde_json::json!({
198
+ "name": format!("{name_ref:?}"),
199
+ "kind": "Unresolved",
200
+ }))
201
+ }
202
+ })
203
+ .collect()
204
+ }
205
+
206
+ /// Filters, paginates, and maps items. Returns `(results, total)` where `total` is the
207
+ /// count of all items passing the filter, and `results` contains only the requested page.
208
+ macro_rules! paginate {
209
+ ($items:expr, $offset:expr, $limit:expr, $filter:expr, $map:expr $(,)?) => {{
210
+ let filtered: Vec<_> = $items.filter($filter).collect();
211
+ let total = filtered.len();
212
+ let results: Vec<serde_json::Value> = filtered
213
+ .into_iter()
214
+ .skip($offset)
215
+ .take($limit)
216
+ .filter_map($map)
217
+ .collect();
218
+ (results, total)
219
+ }};
220
+ }
221
+
222
+ #[tool_router]
223
+ impl RubydexServer {
224
+ #[tool(
225
+ description = "Search for Ruby classes, modules, methods, or constants by name. Use this INSTEAD OF Grep when you know part of a Ruby identifier name and want to find its definition. Returns fully qualified names, kinds, and file locations. Use the `kind` filter (\"Class\", \"Module\", \"Method\", \"Constant\") to narrow results. Set `match_mode` to \"exact\" for precise substring matching or \"fuzzy\" for LSP-style workspace symbol search (default). Results are paginated: the response includes `total` (the full count of matches). If `total` exceeds the number of returned results, use `offset` to fetch subsequent pages."
226
+ )]
227
+ fn search_declarations(&self, Parameters(params): Parameters<SearchDeclarationsParams>) -> String {
228
+ let state = ensure_graph_ready!(self);
229
+ let graph = state.graph.as_ref().unwrap();
230
+ let match_mode = match params.match_mode.as_deref() {
231
+ Some("exact") => rubydex::query::MatchMode::Exact,
232
+ None | Some("fuzzy") => rubydex::query::MatchMode::Fuzzy,
233
+ Some(other) => {
234
+ return serde_json::json!({
235
+ "error": format!("invalid match_mode \"{other}\" (expected \"fuzzy\" or \"exact\")")
236
+ })
237
+ .to_string();
238
+ }
239
+ };
240
+ let ids = rubydex::query::declaration_search(graph, &params.query, &match_mode);
241
+
242
+ let limit = params.limit.filter(|&l| l > 0).unwrap_or(50).min(100); // default 50, max 100
243
+ let offset = params.offset.unwrap_or(0);
244
+ let kind_filter = params.kind.as_deref();
245
+
246
+ let (results, total) = paginate!(
247
+ ids.iter(),
248
+ offset,
249
+ limit,
250
+ |id| {
251
+ let Some(decl) = graph.declarations().get(id) else {
252
+ return false;
253
+ };
254
+ if let Some(kind) = kind_filter {
255
+ decl.kind().eq_ignore_ascii_case(kind)
256
+ } else {
257
+ true
258
+ }
259
+ },
260
+ |id| {
261
+ let decl = graph.declarations().get(id)?;
262
+ let locations: Vec<serde_json::Value> = decl
263
+ .definitions()
264
+ .iter()
265
+ .filter_map(|def_id| {
266
+ let def = graph.definitions().get(def_id)?;
267
+ let doc = graph.documents().get(def.uri_id())?;
268
+ let loc = def.offset().to_location(doc).to_presentation();
269
+ Some(serde_json::json!({
270
+ "path": format_path(doc.uri(), &self.root_path),
271
+ "line": loc.start_line(),
272
+ }))
273
+ })
274
+ .collect();
275
+
276
+ Some(serde_json::json!({
277
+ "name": decl.name(),
278
+ "kind": decl.kind(),
279
+ "locations": locations,
280
+ }))
281
+ },
282
+ );
283
+
284
+ let result = serde_json::json!({
285
+ "results": results,
286
+ "total": total,
287
+ });
288
+
289
+ serde_json::to_string(&result).unwrap_or_else(|_| "{}".to_string())
290
+ }
291
+
292
+ #[tool(
293
+ 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."
294
+ )]
295
+ fn get_declaration(&self, Parameters(params): Parameters<GetDeclarationParams>) -> String {
296
+ let state = ensure_graph_ready!(self);
297
+ let graph = state.graph.as_ref().unwrap();
298
+ let (_, decl) = lookup_declaration!(graph, &params.name);
299
+
300
+ let definitions: Vec<serde_json::Value> = decl
301
+ .definitions()
302
+ .iter()
303
+ .filter_map(|def_id| {
304
+ let def = graph.definitions().get(def_id)?;
305
+ let doc = graph.documents().get(def.uri_id())?;
306
+ let loc = def.offset().to_location(doc).to_presentation();
307
+ let path = format_path(doc.uri(), &self.root_path);
308
+ let comments: Vec<String> = def
309
+ .comments()
310
+ .iter()
311
+ .map(|c| {
312
+ c.string()
313
+ .as_str()
314
+ .strip_prefix("# ")
315
+ .unwrap_or(c.string().as_str())
316
+ .to_string()
317
+ })
318
+ .collect();
319
+
320
+ Some(serde_json::json!({
321
+ "path": path,
322
+ "line": loc.start_line(),
323
+ "comments": comments,
324
+ }))
325
+ })
326
+ .collect();
327
+
328
+ let namespace = decl.as_namespace();
329
+ let ancestors = namespace
330
+ .map(|ns| format_ancestors(graph, ns.ancestors()))
331
+ .unwrap_or_default();
332
+
333
+ let members: Vec<serde_json::Value> = namespace
334
+ .map(|ns| {
335
+ ns.members()
336
+ .iter()
337
+ .filter_map(|(_, member_id)| {
338
+ let member_decl = graph.declarations().get(member_id)?;
339
+ let member_def = member_decl
340
+ .definitions()
341
+ .first()
342
+ .and_then(|def_id| graph.definitions().get(def_id));
343
+
344
+ let mut member = serde_json::json!({
345
+ "name": member_decl.name(),
346
+ "kind": member_decl.kind(),
347
+ });
348
+
349
+ if let Some(def) = member_def
350
+ && let Some(doc) = graph.documents().get(def.uri_id())
351
+ {
352
+ let loc = def.offset().to_location(doc).to_presentation();
353
+ member["location"] = serde_json::json!({
354
+ "path": format_path(doc.uri(), &self.root_path),
355
+ "line": loc.start_line(),
356
+ });
357
+ }
358
+
359
+ Some(member)
360
+ })
361
+ .collect()
362
+ })
363
+ .unwrap_or_default();
364
+
365
+ let result = serde_json::json!({
366
+ "name": decl.name(),
367
+ "kind": decl.kind(),
368
+ "definitions": definitions,
369
+ "ancestors": ancestors,
370
+ "members": members,
371
+ });
372
+
373
+ serde_json::to_string(&result).unwrap_or_else(|_| "{}".to_string())
374
+ }
375
+
376
+ #[tool(
377
+ 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."
378
+ )]
379
+ fn get_descendants(&self, Parameters(params): Parameters<GetDescendantsParams>) -> String {
380
+ let state = ensure_graph_ready!(self);
381
+ let graph = state.graph.as_ref().unwrap();
382
+ let (_, decl) = lookup_declaration!(graph, &params.name);
383
+ let namespace = require_namespace!(decl, &params.name, "get_descendants");
384
+
385
+ let limit = params.limit.filter(|&l| l > 0).unwrap_or(50).min(500); // default 50, max 500
386
+ let offset = params.offset.unwrap_or(0);
387
+
388
+ let (descendants, total) = paginate!(
389
+ namespace.descendants().iter(),
390
+ offset,
391
+ limit,
392
+ |id| graph.declarations().get(id).is_some(),
393
+ |id| {
394
+ let desc_decl = graph.declarations().get(id)?;
395
+ Some(serde_json::json!({
396
+ "name": desc_decl.name(),
397
+ "kind": desc_decl.kind(),
398
+ }))
399
+ },
400
+ );
401
+
402
+ let result = serde_json::json!({
403
+ "name": decl.name(),
404
+ "descendants": descendants,
405
+ "total": total,
406
+ });
407
+
408
+ serde_json::to_string(&result).unwrap_or_else(|_| "{}".to_string())
409
+ }
410
+
411
+ #[tool(
412
+ 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."
413
+ )]
414
+ fn find_constant_references(&self, Parameters(params): Parameters<FindConstantReferencesParams>) -> String {
415
+ let state = ensure_graph_ready!(self);
416
+ let graph = state.graph.as_ref().unwrap();
417
+ let (_, decl) = lookup_declaration!(graph, &params.name);
418
+
419
+ let limit = params.limit.filter(|&l| l > 0).unwrap_or(50).min(200); // default 50, max 200
420
+ let offset = params.offset.unwrap_or(0);
421
+
422
+ let (references, total) = paginate!(
423
+ decl.constant_references().into_iter().flatten(),
424
+ offset,
425
+ limit,
426
+ |ref_id| {
427
+ graph
428
+ .constant_references()
429
+ .get(ref_id)
430
+ .and_then(|r| graph.documents().get(&r.uri_id()))
431
+ .is_some()
432
+ },
433
+ |ref_id| {
434
+ let const_ref = graph.constant_references().get(ref_id)?;
435
+ let doc = graph.documents().get(&const_ref.uri_id())?;
436
+ let loc = const_ref.offset().to_location(doc).to_presentation();
437
+ Some(serde_json::json!({
438
+ "path": format_path(doc.uri(), &self.root_path),
439
+ "line": loc.start_line(),
440
+ "column": loc.start_col(),
441
+ }))
442
+ },
443
+ );
444
+
445
+ let result = serde_json::json!({
446
+ "name": params.name,
447
+ "references": references,
448
+ "total": total,
449
+ });
450
+
451
+ serde_json::to_string(&result).unwrap_or_else(|_| "{}".to_string())
452
+ }
453
+
454
+ #[tool(
455
+ 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."
456
+ )]
457
+ fn get_file_declarations(&self, Parameters(params): Parameters<GetFileDeclarationsParams>) -> String {
458
+ let state = ensure_graph_ready!(self);
459
+ let graph = state.graph.as_ref().unwrap();
460
+
461
+ let absolute_target = if Path::new(&params.file_path).is_absolute() {
462
+ PathBuf::from(&params.file_path)
463
+ } else {
464
+ self.root_path.join(&params.file_path)
465
+ };
466
+ let canonical_target = std::fs::canonicalize(&absolute_target).unwrap_or(absolute_target);
467
+
468
+ let Ok(uri) = Url::from_file_path(&canonical_target) else {
469
+ return error_json(
470
+ "invalid_path",
471
+ &format!("Cannot convert '{}' to a file URI", params.file_path),
472
+ "Use a relative path like 'app/models/user.rb' or an absolute path",
473
+ );
474
+ };
475
+
476
+ let uri_id = UriId::from(uri.as_str());
477
+ let Some(doc) = graph.documents().get(&uri_id) else {
478
+ return error_json(
479
+ "not_found",
480
+ &format!("File '{}' not found in the index", params.file_path),
481
+ "Use a relative path like 'app/models/user.rb' or an absolute path matching the indexed project",
482
+ );
483
+ };
484
+
485
+ let mut declarations: Vec<serde_json::Value> = Vec::new();
486
+
487
+ for def_id in doc.definitions() {
488
+ let Some(def) = graph.definitions().get(def_id) else {
489
+ continue;
490
+ };
491
+
492
+ let loc = def.offset().to_location(doc).to_presentation();
493
+
494
+ let decl_name = graph
495
+ .definition_id_to_declaration_id(*def_id)
496
+ .and_then(|decl_id| graph.declarations().get(decl_id))
497
+ .map(|decl| (decl.name().to_string(), decl.kind()));
498
+
499
+ if let Some((name, kind)) = decl_name {
500
+ declarations.push(serde_json::json!({
501
+ "name": name,
502
+ "kind": kind,
503
+ "line": loc.start_line(),
504
+ }));
505
+ }
506
+ }
507
+
508
+ let result = serde_json::json!({
509
+ "file": format_path(doc.uri(), &self.root_path),
510
+ "declarations": declarations,
511
+ });
512
+
513
+ serde_json::to_string(&result).unwrap_or_else(|_| "{}".to_string())
514
+ }
515
+
516
+ #[tool(
517
+ 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."
518
+ )]
519
+ fn codebase_stats(&self) -> String {
520
+ let state = ensure_graph_ready!(self);
521
+ let graph = state.graph.as_ref().unwrap();
522
+
523
+ let mut breakdown: HashMap<&str, usize> = HashMap::new();
524
+ for decl in graph.declarations().values() {
525
+ *breakdown.entry(decl.kind()).or_default() += 1;
526
+ }
527
+
528
+ let breakdown_json: serde_json::Value = breakdown
529
+ .iter()
530
+ .map(|(k, v)| (k.to_string(), serde_json::json!(v)))
531
+ .collect();
532
+
533
+ let result = serde_json::json!({
534
+ "files": graph.documents().len(),
535
+ "declarations": graph.declarations().len(),
536
+ "definitions": graph.definitions().len(),
537
+ "constant_references": graph.constant_references().len(),
538
+ "method_references": graph.method_references().len(),
539
+ "breakdown_by_kind": breakdown_json,
540
+ });
541
+
542
+ serde_json::to_string(&result).unwrap_or_else(|_| "{}".to_string())
543
+ }
544
+ }
545
+
546
+ const SERVER_INSTRUCTIONS: &str = r#"Rubydex provides semantic Ruby code intelligence.
547
+
548
+ ONLY use these tools for Ruby files (.rb, .rbi, .rbs) — never for Rust, JavaScript, or other languages.
549
+
550
+ Use these tools INSTEAD OF Grep when working with Ruby code structure.
551
+
552
+ Decision guide:
553
+ - Know a name? -> search_declarations (fuzzy search by name)
554
+ - Have an exact fully qualified name? -> get_declaration (full details with docs, ancestors, members)
555
+ - Need reverse hierarchy? -> get_descendants (what inherits from this class/module)
556
+ - Refactoring a class/module/constant? -> find_constant_references (all precise usages across codebase)
557
+ - Exploring a file? -> get_file_declarations (structural overview)
558
+ - Want general statistics? -> codebase_stats (size and composition)
559
+
560
+ Typical workflow: search_declarations -> get_declaration -> find_constant_references.
561
+
562
+ Fully qualified name format: "Foo::Bar" for classes/modules/constants, "Foo::Bar#method_name" for instance methods.
563
+
564
+ Pagination: tools that may return a high number of results include `total` for pagination. When `total` exceeds the number of returned items, use `offset` to fetch the next page.
565
+
566
+ Use Grep instead for: literal string search, log messages, comments, non-Ruby files, or content search rather than structural queries."#;
567
+
568
+ #[tool_handler]
569
+ impl ServerHandler for RubydexServer {
570
+ fn get_info(&self) -> ServerInfo {
571
+ ServerInfo {
572
+ instructions: Some(SERVER_INSTRUCTIONS.into()),
573
+ capabilities: ServerCapabilities::builder().enable_tools().build(),
574
+ ..Default::default()
575
+ }
576
+ }
577
+ }
578
+
579
+ #[cfg(test)]
580
+ mod tests {
581
+ use super::*;
582
+ use rubydex::test_utils::GraphTest;
583
+ use serde_json::Value;
584
+
585
+ fn parse(json_str: &str) -> Value {
586
+ serde_json::from_str(json_str).unwrap()
587
+ }
588
+
589
+ /// Assert a JSON array field contains an entry with the given "name".
590
+ macro_rules! assert_includes {
591
+ ($json:expr, $field:literal, $name:expr) => {{
592
+ let json = &$json;
593
+ let entries = json[$field]
594
+ .as_array()
595
+ .expect(concat!("expected '", $field, "' to be an array"));
596
+ assert!(
597
+ entries.iter().any(|e| e["name"].as_str() == Some($name)),
598
+ "Expected '{}' in '{}', got: {:?}",
599
+ $name,
600
+ $field,
601
+ entries.iter().filter_map(|e| e["name"].as_str()).collect::<Vec<_>>()
602
+ );
603
+ }};
604
+ }
605
+
606
+ /// Extract a JSON field as an array, panicking if not an array.
607
+ macro_rules! array {
608
+ ($json:expr, $field:literal) => {
609
+ $json[$field]
610
+ .as_array()
611
+ .expect(concat!("expected '", $field, "' to be an array"))
612
+ };
613
+ }
614
+
615
+ /// Assert a JSON field equals the expected u64 value.
616
+ macro_rules! assert_json_int {
617
+ ($json:expr, $field:literal, $val:expr) => {
618
+ assert_eq!(
619
+ $json[$field]
620
+ .as_u64()
621
+ .expect(concat!("expected '", $field, "' to be a number")),
622
+ $val
623
+ );
624
+ };
625
+ }
626
+
627
+ fn assert_error(json_str: &str, expected_type: &str) {
628
+ let res = parse(json_str);
629
+ assert_eq!(
630
+ res["error"].as_str(),
631
+ Some(expected_type),
632
+ "Expected error '{expected_type}', got: {res}"
633
+ );
634
+ assert!(res["message"].as_str().is_some());
635
+ assert!(res["suggestion"].as_str().is_some());
636
+ }
637
+
638
+ /// Returns a platform-appropriate test root path and its file URI prefix.
639
+ fn test_root() -> (&'static str, &'static str) {
640
+ if cfg!(windows) {
641
+ ("C:\\test", "file:///C:/test")
642
+ } else {
643
+ ("/test", "file:///test")
644
+ }
645
+ }
646
+
647
+ fn test_uri(filename: &str) -> String {
648
+ let (_, uri_prefix) = test_root();
649
+ format!("{uri_prefix}/{filename}")
650
+ }
651
+
652
+ /// Build a server from a single Ruby source.
653
+ fn server_with_source(source: &str) -> RubydexServer {
654
+ server_with_sources(&[(&test_uri("test.rb"), source)])
655
+ }
656
+
657
+ /// Build a server from multiple `(uri, source)` pairs.
658
+ fn server_with_sources(sources: &[(&str, &str)]) -> RubydexServer {
659
+ let mut gt = GraphTest::new();
660
+ for (uri, source) in sources {
661
+ gt.index_uri(uri, source);
662
+ }
663
+ gt.resolve();
664
+
665
+ let (root, _) = test_root();
666
+ let server = RubydexServer::new(root.to_string());
667
+ {
668
+ let mut state = server.state.write().unwrap();
669
+ state.graph = Some(gt.into_graph());
670
+ }
671
+ server
672
+ }
673
+
674
+ macro_rules! search_declarations {
675
+ ($server:expr, $($field:ident: $val:expr),* $(,)?) => {
676
+ parse(&$server.search_declarations(Parameters(SearchDeclarationsParams {
677
+ match_mode: None,
678
+ $($field: $val,)*
679
+ })))
680
+ };
681
+ }
682
+
683
+ macro_rules! get_descendants {
684
+ ($server:expr, $($field:ident: $val:expr),* $(,)?) => {
685
+ parse(&$server.get_descendants(Parameters(GetDescendantsParams {
686
+ $($field: $val,)*
687
+ })))
688
+ };
689
+ }
690
+
691
+ macro_rules! find_constant_references {
692
+ ($server:expr, $($field:ident: $val:expr),* $(,)?) => {
693
+ parse(&$server.find_constant_references(Parameters(FindConstantReferencesParams {
694
+ $($field: $val,)*
695
+ })))
696
+ };
697
+ }
698
+
699
+ fn get_declaration(server: &RubydexServer, name: &str) -> Value {
700
+ parse(&server.get_declaration(Parameters(GetDeclarationParams { name: name.to_string() })))
701
+ }
702
+
703
+ fn get_file_declarations(server: &RubydexServer, file_path: &str) -> Value {
704
+ parse(&server.get_file_declarations(Parameters(GetFileDeclarationsParams {
705
+ file_path: file_path.to_string(),
706
+ })))
707
+ }
708
+
709
+ // -- search_declarations --
710
+
711
+ #[test]
712
+ fn search_declarations_returns_matching_results() {
713
+ let s = server_with_source("class Dog; end");
714
+ let res = search_declarations!(s, query: "Dog".into(), kind: None, limit: None, offset: None);
715
+
716
+ assert_includes!(res, "results", "Dog");
717
+ assert_json_int!(res, "total", 1);
718
+
719
+ let first = &array!(res, "results")[0];
720
+ assert_eq!(first["name"], "Dog");
721
+ assert_eq!(first["kind"], "Class");
722
+ assert!(first["locations"][0]["path"].as_str().unwrap().ends_with("test.rb"));
723
+ assert_json_int!(first["locations"][0], "line", 1);
724
+ }
725
+
726
+ #[test]
727
+ fn search_declarations_kind_filter() {
728
+ let s = server_with_source(
729
+ "
730
+ class Dog; end
731
+ module Walkable; end
732
+ ",
733
+ );
734
+
735
+ let res = search_declarations!(s, query: "Dog".into(), kind: Some("Class".into()), limit: None, offset: None);
736
+ assert_includes!(res, "results", "Dog");
737
+
738
+ let res = search_declarations!(s, query: "Dog".into(), kind: Some("Module".into()), limit: None, offset: None);
739
+ assert!(array!(res, "results").is_empty());
740
+
741
+ // Case-insensitive
742
+ let res = search_declarations!(s, query: "Dog".into(), kind: Some("class".into()), limit: None, offset: None);
743
+ assert_includes!(res, "results", "Dog");
744
+
745
+ let res = search_declarations!(s, query: "dog".into(), kind: None, limit: None, offset: None);
746
+ assert_includes!(res, "results", "Dog");
747
+ }
748
+
749
+ #[test]
750
+ fn search_declarations_no_match() {
751
+ let s = server_with_source("class Dog; end");
752
+ let res = search_declarations!(s, query: "Zzzzzzzzz".into(), kind: None, limit: None, offset: None);
753
+ assert!(array!(res, "results").is_empty());
754
+ assert_json_int!(res, "total", 0);
755
+ }
756
+
757
+ #[test]
758
+ fn search_declarations_pagination() {
759
+ let s = server_with_source(
760
+ "
761
+ class A; end
762
+ class B; end
763
+ class C; end
764
+ ",
765
+ );
766
+
767
+ let res = search_declarations!(s, query: String::new(), kind: None, limit: Some(2), offset: Some(0));
768
+ assert_eq!(array!(res, "results").len(), 2);
769
+ let total = res["total"].as_u64().unwrap();
770
+
771
+ let res = search_declarations!(s, query: String::new(), kind: None, limit: Some(2), offset: Some(9999));
772
+ assert!(array!(res, "results").is_empty());
773
+ assert_json_int!(res, "total", total);
774
+
775
+ // Verify consecutive pages return different items
776
+ let page1 = search_declarations!(s, query: String::new(), kind: None, limit: Some(1), offset: Some(0));
777
+ let page2 = search_declarations!(s, query: String::new(), kind: None, limit: Some(1), offset: Some(1));
778
+ let name1 = array!(page1, "results")[0]["name"].as_str().unwrap();
779
+ let name2 = array!(page2, "results")[0]["name"].as_str().unwrap();
780
+ assert_ne!(name1, name2, "Page 1 and page 2 should return different items");
781
+ }
782
+
783
+ // -- get_declaration --
784
+
785
+ #[test]
786
+ fn get_declaration_class_with_ancestors_and_members() {
787
+ let s = server_with_source(
788
+ "
789
+ class Animal; end
790
+ class Dog < Animal
791
+ def speak; end
792
+ def fetch; end
793
+ end
794
+ ",
795
+ );
796
+ let res = get_declaration(&s, "Dog");
797
+
798
+ assert_eq!(res["name"], "Dog");
799
+ assert_eq!(res["kind"], "Class");
800
+ assert!(!array!(res, "definitions").is_empty());
801
+ assert_includes!(res, "ancestors", "Animal");
802
+ assert_includes!(res, "members", "Dog#speak()");
803
+ assert_includes!(res, "members", "Dog#fetch()");
804
+
805
+ let member = array!(res, "members")
806
+ .iter()
807
+ .find(|m| m["name"].as_str() == Some("Dog#speak()"))
808
+ .unwrap();
809
+ assert_eq!(member["kind"], "Method");
810
+ assert!(member["location"]["path"].as_str().unwrap().ends_with("test.rb"));
811
+ assert_json_int!(member["location"], "line", 3);
812
+ }
813
+
814
+ #[test]
815
+ fn get_declaration_module() {
816
+ let s = server_with_source("module Greetable; end");
817
+ assert_eq!(get_declaration(&s, "Greetable")["kind"], "Module");
818
+ }
819
+
820
+ #[test]
821
+ fn get_declaration_doc_comments() {
822
+ let s = server_with_source(
823
+ "
824
+ # The Animal class represents all animals.
825
+ class Animal; end
826
+ ",
827
+ );
828
+ let res = get_declaration(&s, "Animal");
829
+ let comments = array!(res["definitions"][0], "comments");
830
+ assert!(
831
+ comments.iter().any(|c| c.as_str().unwrap().contains("Animal")),
832
+ "Expected doc comment on Animal, got: {comments:?}"
833
+ );
834
+ }
835
+
836
+ #[test]
837
+ fn get_declaration_mixin_ancestors() {
838
+ let s = server_with_source(
839
+ "
840
+ module Greetable; end
841
+ class Person
842
+ include Greetable
843
+ end
844
+ ",
845
+ );
846
+ assert_includes!(get_declaration(&s, "Person"), "ancestors", "Greetable");
847
+ }
848
+
849
+ #[test]
850
+ fn get_declaration_constant() {
851
+ let s = server_with_source(
852
+ "
853
+ class Animal
854
+ KINGDOM = 'Animalia'
855
+ end
856
+ ",
857
+ );
858
+ let res = get_declaration(&s, "Animal::KINGDOM");
859
+ assert_eq!(res["kind"], "Constant");
860
+ assert!(array!(res, "ancestors").is_empty());
861
+ assert!(array!(res, "members").is_empty());
862
+ }
863
+
864
+ #[test]
865
+ fn get_declaration_not_found() {
866
+ let s = server_with_source("class Dog; end");
867
+ assert_error(
868
+ &s.get_declaration(Parameters(GetDeclarationParams {
869
+ name: "DoesNotExist".into(),
870
+ })),
871
+ "not_found",
872
+ );
873
+ }
874
+
875
+ // -- get_descendants --
876
+
877
+ #[test]
878
+ fn get_descendants_with_subclasses() {
879
+ let s = server_with_source(
880
+ "
881
+ class Animal; end
882
+ class Dog < Animal; end
883
+ class Cat < Animal; end
884
+ ",
885
+ );
886
+
887
+ let res = get_descendants!(s, name: "Animal".into(), limit: None, offset: None);
888
+ assert_eq!(res["name"], "Animal");
889
+ assert_includes!(res, "descendants", "Animal");
890
+ assert_includes!(res, "descendants", "Dog");
891
+ assert_includes!(res, "descendants", "Cat");
892
+ assert_json_int!(res, "total", 3);
893
+
894
+ // Cat: 1 descendant (itself only, no subclasses)
895
+ let res = get_descendants!(s, name: "Cat".into(), limit: None, offset: None);
896
+ assert_json_int!(res, "total", 1);
897
+ }
898
+
899
+ #[test]
900
+ fn get_descendants_module() {
901
+ let s = server_with_source(
902
+ "
903
+ module Greetable; end
904
+
905
+ class Person
906
+ include Greetable
907
+ end
908
+ ",
909
+ );
910
+ let res = get_descendants!(s, name: "Greetable".into(), limit: None, offset: None);
911
+ assert_includes!(res, "descendants", "Person");
912
+ }
913
+
914
+ #[test]
915
+ fn get_descendants_inheritance_chain() {
916
+ let s = server_with_source(
917
+ "
918
+ class Foo; end
919
+ class Bar < Foo; end
920
+ class Baz < Bar; end
921
+ ",
922
+ );
923
+ let res = get_descendants!(s, name: "Foo".into(), limit: None, offset: None);
924
+ assert_includes!(res, "descendants", "Bar");
925
+ assert_includes!(res, "descendants", "Baz");
926
+ }
927
+
928
+ #[test]
929
+ fn get_descendants_pagination() {
930
+ let s = server_with_source(
931
+ "
932
+ class Animal; end
933
+ class Dog < Animal; end
934
+ class Cat < Animal; end
935
+ ",
936
+ );
937
+ let page1 = get_descendants!(s, name: "Animal".into(), limit: Some(1), offset: Some(0));
938
+ assert_eq!(array!(page1, "descendants").len(), 1);
939
+ assert_json_int!(page1, "total", 3);
940
+
941
+ let page2 = get_descendants!(s, name: "Animal".into(), limit: Some(1), offset: Some(1));
942
+ let name1 = array!(page1, "descendants")[0]["name"].as_str().unwrap();
943
+ let name2 = array!(page2, "descendants")[0]["name"].as_str().unwrap();
944
+ assert_ne!(name1, name2, "Page 1 and page 2 should return different descendants");
945
+ }
946
+
947
+ #[test]
948
+ fn get_descendants_not_found() {
949
+ let s = server_with_source("class Dog; end");
950
+ assert_error(
951
+ &s.get_descendants(Parameters(GetDescendantsParams {
952
+ name: "DoesNotExist".into(),
953
+ limit: None,
954
+ offset: None,
955
+ })),
956
+ "not_found",
957
+ );
958
+ }
959
+
960
+ #[test]
961
+ fn get_descendants_invalid_kind() {
962
+ let s = server_with_source(
963
+ "
964
+ class Animal
965
+ KINGDOM = 'Animalia'
966
+ end
967
+ ",
968
+ );
969
+ assert_error(
970
+ &s.get_descendants(Parameters(GetDescendantsParams {
971
+ name: "Animal::KINGDOM".into(),
972
+ limit: None,
973
+ offset: None,
974
+ })),
975
+ "invalid_kind",
976
+ );
977
+ }
978
+
979
+ // -- find_constant_references --
980
+
981
+ #[test]
982
+ fn find_constant_references_success() {
983
+ let s = server_with_source(
984
+ "
985
+ class Animal; end
986
+ class Dog < Animal; end
987
+ class Kennel
988
+ def build
989
+ Animal.new
990
+ end
991
+ end
992
+ ",
993
+ );
994
+ let res = find_constant_references!(s, name: "Animal".into(), limit: None, offset: None);
995
+
996
+ assert_eq!(res["name"], "Animal");
997
+ assert_eq!(array!(res, "references").len(), 2);
998
+ assert_json_int!(res, "total", 2);
999
+ let first_ref = &array!(res, "references")[0];
1000
+ assert!(first_ref["path"].as_str().unwrap().ends_with("test.rb"));
1001
+ assert_json_int!(first_ref, "line", 2);
1002
+ assert_json_int!(first_ref, "column", 13);
1003
+ }
1004
+
1005
+ #[test]
1006
+ fn find_constant_references_cross_file() {
1007
+ let models = test_uri("models.rb");
1008
+ let services = test_uri("services.rb");
1009
+ let s = server_with_sources(&[
1010
+ (&models, "class Dog; end"),
1011
+ (
1012
+ &services,
1013
+ "
1014
+ class Kennel
1015
+ def adopt
1016
+ Dog.new
1017
+ end
1018
+ end
1019
+ ",
1020
+ ),
1021
+ ]);
1022
+ let res = find_constant_references!(s, name: "Dog".into(), limit: None, offset: None);
1023
+ let paths: Vec<&str> = array!(res, "references")
1024
+ .iter()
1025
+ .filter_map(|r| r["path"].as_str())
1026
+ .collect();
1027
+ assert!(
1028
+ paths.iter().any(|p| p.contains("services")),
1029
+ "Expected cross-file ref from services, got: {paths:?}"
1030
+ );
1031
+ }
1032
+
1033
+ #[test]
1034
+ fn find_constant_references_pagination() {
1035
+ let s = server_with_source(
1036
+ "
1037
+ class Animal; end
1038
+ class Dog < Animal; end
1039
+ class Cat < Animal; end
1040
+ class Kennel
1041
+ def build
1042
+ Animal.new
1043
+ end
1044
+ end
1045
+ ",
1046
+ );
1047
+ let full = find_constant_references!(s, name: "Animal".into(), limit: None, offset: None);
1048
+ let full_total = full["total"].as_u64().unwrap();
1049
+
1050
+ let page = find_constant_references!(s, name: "Animal".into(), limit: Some(1), offset: Some(0));
1051
+ assert_eq!(array!(page, "references").len(), 1);
1052
+ assert_json_int!(page, "total", full_total);
1053
+ }
1054
+
1055
+ #[test]
1056
+ fn find_constant_references_not_found() {
1057
+ let s = server_with_source("class Dog; end");
1058
+ assert_error(
1059
+ &s.find_constant_references(Parameters(FindConstantReferencesParams {
1060
+ name: "DoesNotExist".into(),
1061
+ limit: None,
1062
+ offset: None,
1063
+ })),
1064
+ "not_found",
1065
+ );
1066
+ }
1067
+
1068
+ // -- get_file_declarations --
1069
+
1070
+ #[test]
1071
+ fn get_file_declarations_success() {
1072
+ let s = server_with_source(
1073
+ "
1074
+ class Animal; end
1075
+ class Dog < Animal; end
1076
+ module Greetable; end
1077
+ ",
1078
+ );
1079
+ let res = get_file_declarations(&s, "test.rb");
1080
+
1081
+ assert_includes!(res, "declarations", "Animal");
1082
+ assert_includes!(res, "declarations", "Dog");
1083
+ assert_includes!(res, "declarations", "Greetable");
1084
+ assert_eq!(array!(res, "declarations")[0]["name"], "Animal");
1085
+ assert_eq!(array!(res, "declarations")[0]["kind"], "Class");
1086
+ assert_json_int!(array!(res, "declarations")[0], "line", 1);
1087
+ }
1088
+
1089
+ #[test]
1090
+ fn get_file_declarations_multiple_files() {
1091
+ let models = test_uri("models.rb");
1092
+ let services = test_uri("services.rb");
1093
+ let s = server_with_sources(&[(&models, "class Animal; end"), (&services, "class Kennel; end")]);
1094
+ let res = get_file_declarations(&s, "services.rb");
1095
+ assert_includes!(res, "declarations", "Kennel");
1096
+ }
1097
+
1098
+ #[test]
1099
+ fn get_file_declarations_not_found() {
1100
+ let s = server_with_source("class Dog; end");
1101
+ assert_error(
1102
+ &s.get_file_declarations(Parameters(GetFileDeclarationsParams {
1103
+ file_path: "nonexistent.rb".into(),
1104
+ })),
1105
+ "not_found",
1106
+ );
1107
+ }
1108
+
1109
+ // -- codebase_stats --
1110
+
1111
+ #[test]
1112
+ fn codebase_stats_returns_counts() {
1113
+ let a = test_uri("a.rb");
1114
+ let b = test_uri("b.rb");
1115
+ let s = server_with_sources(&[(&a, "class Animal; end"), (&b, "module Greetable; end")]);
1116
+ let res = parse(&s.codebase_stats());
1117
+
1118
+ assert_eq!(res["files"], 3);
1119
+ assert_json_int!(res, "declarations", 7);
1120
+ assert_json_int!(res, "definitions", 7);
1121
+
1122
+ let breakdown = &res["breakdown_by_kind"];
1123
+ assert_json_int!(breakdown, "Class", 5);
1124
+ assert_json_int!(breakdown, "Module", 2);
1125
+ }
1126
+
1127
+ // -- error states --
1128
+
1129
+ #[test]
1130
+ fn returns_indexing_error_when_graph_not_ready() {
1131
+ let server = RubydexServer::new("/test".to_string());
1132
+ // graph is None (still indexing)
1133
+ assert_error(&server.codebase_stats(), "indexing");
1134
+ }
1135
+
1136
+ #[test]
1137
+ fn returns_indexing_failed_error() {
1138
+ let server = RubydexServer::new("/test".to_string());
1139
+ {
1140
+ let mut state = server.state.write().unwrap();
1141
+ state.error = Some("something went wrong".into());
1142
+ }
1143
+ assert_error(&server.codebase_stats(), "indexing_failed");
1144
+ }
1145
+ }