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