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.
- checksums.yaml +7 -0
- data/LICENSE.txt +23 -0
- data/README.md +125 -0
- data/THIRD_PARTY_LICENSES.html +4562 -0
- data/exe/rdx +47 -0
- data/ext/rubydex/declaration.c +453 -0
- data/ext/rubydex/declaration.h +23 -0
- data/ext/rubydex/definition.c +284 -0
- data/ext/rubydex/definition.h +28 -0
- data/ext/rubydex/diagnostic.c +6 -0
- data/ext/rubydex/diagnostic.h +11 -0
- data/ext/rubydex/document.c +97 -0
- data/ext/rubydex/document.h +10 -0
- data/ext/rubydex/extconf.rb +138 -0
- data/ext/rubydex/graph.c +681 -0
- data/ext/rubydex/graph.h +10 -0
- data/ext/rubydex/handle.h +44 -0
- data/ext/rubydex/location.c +22 -0
- data/ext/rubydex/location.h +15 -0
- data/ext/rubydex/reference.c +123 -0
- data/ext/rubydex/reference.h +15 -0
- data/ext/rubydex/rubydex.c +22 -0
- data/ext/rubydex/utils.c +108 -0
- data/ext/rubydex/utils.h +34 -0
- data/lib/rubydex/3.2/rubydex.so +0 -0
- data/lib/rubydex/3.3/rubydex.so +0 -0
- data/lib/rubydex/3.4/rubydex.so +0 -0
- data/lib/rubydex/4.0/rubydex.so +0 -0
- data/lib/rubydex/comment.rb +17 -0
- data/lib/rubydex/diagnostic.rb +21 -0
- data/lib/rubydex/failures.rb +15 -0
- data/lib/rubydex/graph.rb +98 -0
- data/lib/rubydex/keyword.rb +17 -0
- data/lib/rubydex/keyword_parameter.rb +13 -0
- data/lib/rubydex/librubydex_sys.so +0 -0
- data/lib/rubydex/location.rb +90 -0
- data/lib/rubydex/mixin.rb +22 -0
- data/lib/rubydex/version.rb +5 -0
- data/lib/rubydex.rb +23 -0
- data/rbi/rubydex.rbi +422 -0
- data/rust/Cargo.lock +1851 -0
- data/rust/Cargo.toml +29 -0
- data/rust/about.hbs +78 -0
- data/rust/about.toml +10 -0
- data/rust/rubydex/Cargo.toml +42 -0
- data/rust/rubydex/src/compile_assertions.rs +13 -0
- data/rust/rubydex/src/diagnostic.rs +110 -0
- data/rust/rubydex/src/errors.rs +28 -0
- data/rust/rubydex/src/indexing/local_graph.rs +224 -0
- data/rust/rubydex/src/indexing/rbs_indexer.rs +1551 -0
- data/rust/rubydex/src/indexing/ruby_indexer.rs +2329 -0
- data/rust/rubydex/src/indexing/ruby_indexer_tests.rs +4962 -0
- data/rust/rubydex/src/indexing.rs +210 -0
- data/rust/rubydex/src/integrity.rs +279 -0
- data/rust/rubydex/src/job_queue.rs +205 -0
- data/rust/rubydex/src/lib.rs +17 -0
- data/rust/rubydex/src/listing.rs +371 -0
- data/rust/rubydex/src/main.rs +160 -0
- data/rust/rubydex/src/model/built_in.rs +83 -0
- data/rust/rubydex/src/model/comment.rs +24 -0
- data/rust/rubydex/src/model/declaration.rs +671 -0
- data/rust/rubydex/src/model/definitions.rs +1682 -0
- data/rust/rubydex/src/model/document.rs +222 -0
- data/rust/rubydex/src/model/encoding.rs +22 -0
- data/rust/rubydex/src/model/graph.rs +3754 -0
- data/rust/rubydex/src/model/id.rs +110 -0
- data/rust/rubydex/src/model/identity_maps.rs +58 -0
- data/rust/rubydex/src/model/ids.rs +60 -0
- data/rust/rubydex/src/model/keywords.rs +256 -0
- data/rust/rubydex/src/model/name.rs +298 -0
- data/rust/rubydex/src/model/references.rs +111 -0
- data/rust/rubydex/src/model/string_ref.rs +50 -0
- data/rust/rubydex/src/model/visibility.rs +41 -0
- data/rust/rubydex/src/model.rs +15 -0
- data/rust/rubydex/src/offset.rs +147 -0
- data/rust/rubydex/src/position.rs +6 -0
- data/rust/rubydex/src/query.rs +1841 -0
- data/rust/rubydex/src/resolution.rs +6517 -0
- data/rust/rubydex/src/stats/memory.rs +71 -0
- data/rust/rubydex/src/stats/orphan_report.rs +264 -0
- data/rust/rubydex/src/stats/timer.rs +127 -0
- data/rust/rubydex/src/stats.rs +11 -0
- data/rust/rubydex/src/test_utils/context.rs +226 -0
- data/rust/rubydex/src/test_utils/graph_test.rs +730 -0
- data/rust/rubydex/src/test_utils/local_graph_test.rs +602 -0
- data/rust/rubydex/src/test_utils.rs +52 -0
- data/rust/rubydex/src/visualization/dot.rs +192 -0
- data/rust/rubydex/src/visualization.rs +6 -0
- data/rust/rubydex/tests/cli.rs +185 -0
- data/rust/rubydex-mcp/Cargo.toml +28 -0
- data/rust/rubydex-mcp/src/main.rs +48 -0
- data/rust/rubydex-mcp/src/server.rs +1145 -0
- data/rust/rubydex-mcp/src/tools.rs +49 -0
- data/rust/rubydex-mcp/tests/mcp.rs +302 -0
- data/rust/rubydex-sys/Cargo.toml +20 -0
- data/rust/rubydex-sys/build.rs +14 -0
- data/rust/rubydex-sys/cbindgen.toml +12 -0
- data/rust/rubydex-sys/src/declaration_api.rs +485 -0
- data/rust/rubydex-sys/src/definition_api.rs +443 -0
- data/rust/rubydex-sys/src/diagnostic_api.rs +99 -0
- data/rust/rubydex-sys/src/document_api.rs +85 -0
- data/rust/rubydex-sys/src/graph_api.rs +948 -0
- data/rust/rubydex-sys/src/lib.rs +79 -0
- data/rust/rubydex-sys/src/location_api.rs +79 -0
- data/rust/rubydex-sys/src/name_api.rs +135 -0
- data/rust/rubydex-sys/src/reference_api.rs +267 -0
- data/rust/rubydex-sys/src/utils.rs +70 -0
- data/rust/rustfmt.toml +2 -0
- 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, ¶ms.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, ¶ms.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, ¶ms.name);
|
|
383
|
+
let namespace = require_namespace!(decl, ¶ms.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, ¶ms.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(¶ms.file_path).is_absolute() {
|
|
462
|
+
PathBuf::from(¶ms.file_path)
|
|
463
|
+
} else {
|
|
464
|
+
self.root_path.join(¶ms.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
|
+
}
|