rubydex 0.2.9 → 0.4.0

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 (96) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +110 -6
  3. data/THIRD_PARTY_LICENSES.html +271 -2
  4. data/exe/rdx +2 -73
  5. data/ext/rubydex/config.c +140 -0
  6. data/ext/rubydex/config.h +16 -0
  7. data/ext/rubydex/declaration.c +1 -1
  8. data/ext/rubydex/definition.c +32 -4
  9. data/ext/rubydex/diagnostic.c +75 -1
  10. data/ext/rubydex/diagnostic.h +2 -0
  11. data/ext/rubydex/graph.c +27 -48
  12. data/ext/rubydex/graph.h +6 -0
  13. data/ext/rubydex/query.c +487 -0
  14. data/ext/rubydex/query.h +8 -0
  15. data/ext/rubydex/reference.c +60 -0
  16. data/ext/rubydex/rubydex.c +4 -0
  17. data/ext/rubydex/utils.c +23 -4
  18. data/ext/rubydex/utils.h +5 -0
  19. data/lib/ruby_lsp/rubydex/addon.rb +211 -0
  20. data/lib/rubydex/cli/command/console.rb +55 -0
  21. data/lib/rubydex/cli/command/lint/explain.rb +74 -0
  22. data/lib/rubydex/cli/command/lint.rb +202 -0
  23. data/lib/rubydex/cli/command/mcp.rb +30 -0
  24. data/lib/rubydex/cli/command/query.rb +70 -0
  25. data/lib/rubydex/cli/command/skill.rb +69 -0
  26. data/lib/rubydex/cli/command.rb +168 -0
  27. data/lib/rubydex/cli.rb +93 -0
  28. data/lib/rubydex/config.rb +59 -0
  29. data/lib/rubydex/diagnostic.rb +12 -3
  30. data/lib/rubydex/errors.rb +42 -1
  31. data/lib/rubydex/graph.rb +10 -3
  32. data/lib/rubydex/linter/custom_rule.rb +97 -0
  33. data/lib/rubydex/linter/helpers/path_helpers.rb +78 -0
  34. data/lib/rubydex/linter/helpers/source_access_helpers.rb +31 -0
  35. data/lib/rubydex/linter/rule_loader.rb +36 -0
  36. data/lib/rubydex/linter/rule_test_case.rb +343 -0
  37. data/lib/rubydex/linter/runner.rb +56 -0
  38. data/lib/rubydex/linter.rb +19 -0
  39. data/lib/rubydex/location.rb +3 -0
  40. data/lib/rubydex/mcp_server.rb +1 -2
  41. data/lib/rubydex/related_information.rb +17 -0
  42. data/lib/rubydex/rule.rb +33 -0
  43. data/lib/rubydex/severity.rb +70 -0
  44. data/lib/rubydex/skill.rb +88 -0
  45. data/lib/rubydex/skill_registry.rb +62 -0
  46. data/lib/rubydex/version.rb +1 -1
  47. data/lib/rubydex.rb +6 -0
  48. data/lib/rubydex_linter/rules/rule_structure.rb +125 -0
  49. data/rbi/rubydex.rbi +578 -15
  50. data/rust/Cargo.lock +7 -0
  51. data/rust/rubydex/Cargo.toml +1 -0
  52. data/rust/rubydex/benches/graph_memory.rs +20 -4
  53. data/rust/rubydex/src/compile_assertions.rs +15 -0
  54. data/rust/rubydex/src/config.rs +538 -157
  55. data/rust/rubydex/src/diagnostic.rs +66 -40
  56. data/rust/rubydex/src/errors.rs +0 -1
  57. data/rust/rubydex/src/indexing/local_graph.rs +6 -5
  58. data/rust/rubydex/src/indexing/rbs_indexer.rs +284 -8
  59. data/rust/rubydex/src/indexing/ruby_indexer.rs +59 -70
  60. data/rust/rubydex/src/indexing/ruby_indexer_tests.rs +195 -86
  61. data/rust/rubydex/src/lib.rs +1 -0
  62. data/rust/rubydex/src/listing.rs +26 -1
  63. data/rust/rubydex/src/main.rs +9 -128
  64. data/rust/rubydex/src/model/declaration.rs +301 -229
  65. data/rust/rubydex/src/model/definitions.rs +27 -26
  66. data/rust/rubydex/src/model/document.rs +43 -7
  67. data/rust/rubydex/src/model/graph.rs +67 -68
  68. data/rust/rubydex/src/model/id.rs +55 -0
  69. data/rust/rubydex/src/model/ids.rs +21 -9
  70. data/rust/rubydex/src/model/name.rs +88 -19
  71. data/rust/rubydex/src/model/references.rs +16 -13
  72. data/rust/rubydex/src/operation/ruby_builder.rs +78 -104
  73. data/rust/rubydex/src/path_helpers.rs +77 -0
  74. data/rust/rubydex/src/query/cypher/schema.rs +853 -0
  75. data/rust/rubydex/src/query/cypher/schema_info.rs +161 -0
  76. data/rust/rubydex/src/query/cypher/tests.rs +253 -0
  77. data/rust/rubydex/src/query/cypher.rs +54 -0
  78. data/rust/rubydex/src/query.rs +125 -43
  79. data/rust/rubydex/src/resolution.rs +368 -395
  80. data/rust/rubydex/src/resolution_tests.rs +504 -78
  81. data/rust/rubydex/src/test_utils/context.rs +2 -1
  82. data/rust/rubydex/src/test_utils/graph_test.rs +26 -12
  83. data/rust/rubydex/src/test_utils/local_graph_test.rs +19 -0
  84. data/rust/rubydex/tests/cli.rs +4 -4
  85. data/rust/rubydex-sys/src/config_api.rs +205 -0
  86. data/rust/rubydex-sys/src/cypher_api.rs +791 -0
  87. data/rust/rubydex-sys/src/declaration_api.rs +6 -3
  88. data/rust/rubydex-sys/src/definition_api.rs +27 -7
  89. data/rust/rubydex-sys/src/diagnostic_api.rs +77 -8
  90. data/rust/rubydex-sys/src/graph_api.rs +31 -68
  91. data/rust/rubydex-sys/src/lib.rs +2 -0
  92. data/rust/rubydex-sys/src/name_api.rs +2 -6
  93. data/rust/rubydex-sys/src/reference_api.rs +58 -12
  94. data/rust/rubydex-sys/src/utils.rs +37 -0
  95. data/skills/send-private-method/SKILL.md +133 -0
  96. metadata +37 -2
@@ -0,0 +1,853 @@
1
+ //! Maps the rubydex [`Graph`] onto a property-graph schema for Cypher execution.
2
+ //!
3
+ //! Node labels:
4
+ //! - `Document` — a source file.
5
+ //! - `Definition` — a per-file occurrence of a Ruby construct.
6
+ //! - `Declaration` — the global, merged concept of a named entity. Declarations also carry
7
+ //! kind sub-labels (`Class`, `Module`, `SingletonClass`, `Method`, `Constant`, `ConstantAlias`,
8
+ //! `GlobalVariable`, `InstanceVariable`, `ClassVariable`) plus the grouping label `Namespace`
9
+ //! (any of `Class`/`Module`/`SingletonClass`).
10
+ //!
11
+ //! Relationship types mirror `dot.rs`:
12
+ //! - `DEFINES`: `Document` → `Definition`
13
+ //! - `DECLARES`: `Definition` → `Declaration`
14
+ //! - `CONTAINS`: `Definition` → `Definition` (lexical nesting in one file, e.g. a class written
15
+ //! inside a module; the source-level counterpart of declaration-level `OWNS`)
16
+ //! - `HAS_PARENT`: `Declaration` → `Declaration` (direct superclass; reverse-traverse for direct
17
+ //! subclasses, `*` for the full chain)
18
+ //! - `INCLUDES` / `PREPENDS` / `EXTENDS`: `Declaration` → `Declaration` (mixins)
19
+ //! - `OWNS`: `Declaration` → `Declaration` (declaration-level membership, e.g. a namespace's methods
20
+ //! and nested constants, merged across all files)
21
+ //! - `HAS_ANCESTOR`: `Declaration` → `Declaration` (linearized ancestor chain, incl. modules)
22
+ //! - `HAS_DESCENDANT`: `Declaration` → `Declaration` (reverse of `HAS_ANCESTOR`)
23
+ //! - `REFERENCES`: `Document` → `Declaration` (constant references)
24
+
25
+ use std::collections::{HashSet, VecDeque};
26
+
27
+ use crate::model::declaration::Declaration;
28
+ use crate::model::definitions::{Definition, Mixin};
29
+ use crate::model::graph::Graph;
30
+ use crate::model::ids::{ConstantReferenceId, DeclarationId, DefinitionId, UriId};
31
+
32
+ use cypher_parser::{CypherValue, GraphProvider};
33
+
34
+ /// A handle to a node in the graph.
35
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
36
+ pub enum NodeRef {
37
+ Declaration(DeclarationId),
38
+ Definition(DefinitionId),
39
+ Document(UriId),
40
+ }
41
+
42
+ impl NodeRef {
43
+ /// Decodes the opaque id produced by [`GraphProvider::node_id`] back into a `NodeRef`. Returns
44
+ /// `None` if the string is not a recognized `tag:id` form.
45
+ #[must_use]
46
+ pub fn decode(encoded: &str) -> Option<NodeRef> {
47
+ let (tag, rest) = encoded.split_once(':')?;
48
+ let value: u64 = rest.parse().ok()?;
49
+ match tag {
50
+ "decl" => Some(NodeRef::Declaration(DeclarationId::new(value))),
51
+ "def" => Some(NodeRef::Definition(DefinitionId::new(value))),
52
+ "doc" => Some(NodeRef::Document(UriId::new(value))),
53
+ _ => None,
54
+ }
55
+ }
56
+ }
57
+
58
+ /// A relationship type.
59
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
60
+ pub enum RelType {
61
+ /// `Document` → `Definition`: a file defines a construct occurrence.
62
+ Defines,
63
+ /// `Definition` → `Declaration`: a per-file occurrence declares the global merged entity.
64
+ Declares,
65
+ /// `Definition` → `Definition`: lexical nesting within a single file, e.g. a class written
66
+ /// inside a module. This is the source-level structure; the declaration-level (merged across
67
+ /// files) counterpart is [`RelType::Owns`].
68
+ Contains,
69
+ /// `Declaration` → `Declaration`: the direct superclass (a single hop). Reverse-traverse for
70
+ /// direct subclasses, or use `*` for the full class chain. For the transitive relation
71
+ /// (including modules) see [`RelType::HasAncestor`].
72
+ HasParent,
73
+ /// `Declaration` → `Declaration`: an included module mixin.
74
+ Includes,
75
+ /// `Declaration` → `Declaration`: a prepended module mixin.
76
+ Prepends,
77
+ /// `Declaration` → `Declaration`: an extended module mixin.
78
+ Extends,
79
+ /// `Declaration` → `Declaration`: declaration-level membership (a namespace's methods and
80
+ /// nested constants), merged across all files. The per-file source counterpart is
81
+ /// [`RelType::Contains`].
82
+ Owns,
83
+ /// `Declaration` → `Declaration`: an entry in the linearized ancestor chain (transitive
84
+ /// superclasses plus included/prepended modules).
85
+ HasAncestor,
86
+ /// `Declaration` → `Declaration`: the reverse of [`RelType::HasAncestor`].
87
+ HasDescendant,
88
+ /// `Document` → `Declaration`: a constant reference in the file resolves to a declaration.
89
+ References,
90
+ }
91
+
92
+ /// Catalog metadata for a relationship type: the type itself, its canonical name, endpoint labels,
93
+ /// and a short description. Entries live in [`REL_SCHEMAS`], the single source of truth that
94
+ /// `RelType`'s accessors and the `--schema` catalog both derive from.
95
+ pub struct RelSchema {
96
+ pub rel: RelType,
97
+ pub name: &'static str,
98
+ pub from: &'static str,
99
+ pub to: &'static str,
100
+ pub description: &'static str,
101
+ }
102
+
103
+ /// The single source of truth for every relationship type: its name, endpoints, and description.
104
+ /// `RelType::all`/`name`/`parse`/`schema` and the `--schema` catalog all derive from this table.
105
+ const REL_SCHEMAS: &[RelSchema] = &[
106
+ RelSchema {
107
+ rel: RelType::Defines,
108
+ name: "DEFINES",
109
+ from: "Document",
110
+ to: "Definition",
111
+ description: "A file defines a construct occurrence",
112
+ },
113
+ RelSchema {
114
+ rel: RelType::Declares,
115
+ name: "DECLARES",
116
+ from: "Definition",
117
+ to: "Declaration",
118
+ description: "An occurrence contributes to a declaration",
119
+ },
120
+ RelSchema {
121
+ rel: RelType::Contains,
122
+ name: "CONTAINS",
123
+ from: "Definition",
124
+ to: "Definition",
125
+ description: "Lexical nesting of definitions",
126
+ },
127
+ RelSchema {
128
+ rel: RelType::HasParent,
129
+ name: "HAS_PARENT",
130
+ from: "Class",
131
+ to: "Class",
132
+ description: "Direct superclass (use `*` for the full chain)",
133
+ },
134
+ RelSchema {
135
+ rel: RelType::Includes,
136
+ name: "INCLUDES",
137
+ from: "Declaration",
138
+ to: "Declaration",
139
+ description: "`include` mixin",
140
+ },
141
+ RelSchema {
142
+ rel: RelType::Prepends,
143
+ name: "PREPENDS",
144
+ from: "Declaration",
145
+ to: "Declaration",
146
+ description: "`prepend` mixin",
147
+ },
148
+ RelSchema {
149
+ rel: RelType::Extends,
150
+ name: "EXTENDS",
151
+ from: "Declaration",
152
+ to: "Declaration",
153
+ description: "`extend` mixin",
154
+ },
155
+ RelSchema {
156
+ rel: RelType::Owns,
157
+ name: "OWNS",
158
+ from: "Declaration",
159
+ to: "Declaration",
160
+ description: "A namespace owns a member declaration",
161
+ },
162
+ RelSchema {
163
+ rel: RelType::HasAncestor,
164
+ name: "HAS_ANCESTOR",
165
+ from: "Declaration",
166
+ to: "Declaration",
167
+ description: "An entry in the linearized ancestor chain (incl. modules)",
168
+ },
169
+ RelSchema {
170
+ rel: RelType::HasDescendant,
171
+ name: "HAS_DESCENDANT",
172
+ from: "Declaration",
173
+ to: "Declaration",
174
+ description: "A declaration that descends from this one",
175
+ },
176
+ RelSchema {
177
+ rel: RelType::References,
178
+ name: "REFERENCES",
179
+ from: "Document",
180
+ to: "Declaration",
181
+ description: "A file references a constant declaration",
182
+ },
183
+ ];
184
+
185
+ impl RelType {
186
+ /// All relationship types, in catalog order. Used when a pattern leaves the type unspecified.
187
+ pub fn all() -> impl Iterator<Item = RelType> {
188
+ REL_SCHEMAS.iter().map(|schema| schema.rel)
189
+ }
190
+
191
+ /// The catalog metadata for this relationship type, looked up in the [`REL_SCHEMAS`] table.
192
+ ///
193
+ /// # Panics
194
+ ///
195
+ /// Panics if a `RelType` variant has no `REL_SCHEMAS` entry. The table is exhaustive by
196
+ /// construction (guarded by a test), so this cannot happen in practice.
197
+ #[must_use]
198
+ pub fn schema(self) -> &'static RelSchema {
199
+ REL_SCHEMAS
200
+ .iter()
201
+ .find(|schema| schema.rel == self)
202
+ .expect("every RelType has a REL_SCHEMAS entry")
203
+ }
204
+
205
+ /// The canonical uppercase name of this relationship type.
206
+ #[must_use]
207
+ pub fn name(self) -> &'static str {
208
+ self.schema().name
209
+ }
210
+
211
+ /// Parses a relationship type name (case-insensitive). Returns `None` if unknown.
212
+ #[must_use]
213
+ pub fn parse(name: &str) -> Option<Self> {
214
+ let upper = name.to_ascii_uppercase();
215
+ REL_SCHEMAS
216
+ .iter()
217
+ .find(|schema| schema.name == upper)
218
+ .map(|schema| schema.rel)
219
+ }
220
+ }
221
+
222
+ /// A node label and what it matches, for the `--schema` catalog.
223
+ pub struct LabelSchema {
224
+ pub label: &'static str,
225
+ pub matches: &'static str,
226
+ pub description: &'static str,
227
+ }
228
+
229
+ /// A property exposed on a node type, for the `--schema` catalog.
230
+ pub struct PropertySchema {
231
+ pub node_type: &'static str,
232
+ pub property: &'static str,
233
+ pub description: &'static str,
234
+ }
235
+
236
+ /// The node labels queries can match against. Single source of truth for the `--schema` catalog;
237
+ /// the matching behavior lives in [`scan_label`] and [`declaration_matches_label`].
238
+ pub const NODE_LABELS: &[LabelSchema] = &[
239
+ LabelSchema {
240
+ label: "Document",
241
+ matches: "source files",
242
+ description: "A source file in the workspace",
243
+ },
244
+ LabelSchema {
245
+ label: "Definition",
246
+ matches: "per-file occurrences",
247
+ description: "A single occurrence of a Ruby construct in one file",
248
+ },
249
+ LabelSchema {
250
+ label: "Declaration",
251
+ matches: "merged entities",
252
+ description: "The global, merged concept of a named entity",
253
+ },
254
+ LabelSchema {
255
+ label: "Namespace",
256
+ matches: "Class | Module | SingletonClass declarations",
257
+ description: "Grouping label for namespace-like declarations",
258
+ },
259
+ LabelSchema {
260
+ label: "Class",
261
+ matches: "declarations of kind Class",
262
+ description: "A class declaration",
263
+ },
264
+ LabelSchema {
265
+ label: "Module",
266
+ matches: "declarations of kind Module",
267
+ description: "A module declaration",
268
+ },
269
+ LabelSchema {
270
+ label: "SingletonClass",
271
+ matches: "declarations of kind SingletonClass",
272
+ description: "A singleton class declaration",
273
+ },
274
+ LabelSchema {
275
+ label: "Method",
276
+ matches: "declarations of kind Method",
277
+ description: "A method declaration",
278
+ },
279
+ LabelSchema {
280
+ label: "Constant",
281
+ matches: "declarations of kind Constant",
282
+ description: "A constant declaration",
283
+ },
284
+ LabelSchema {
285
+ label: "ConstantAlias",
286
+ matches: "declarations of kind ConstantAlias",
287
+ description: "A constant alias declaration",
288
+ },
289
+ LabelSchema {
290
+ label: "GlobalVariable",
291
+ matches: "declarations of kind GlobalVariable",
292
+ description: "A global variable declaration",
293
+ },
294
+ LabelSchema {
295
+ label: "InstanceVariable",
296
+ matches: "declarations of kind InstanceVariable",
297
+ description: "An instance variable declaration",
298
+ },
299
+ LabelSchema {
300
+ label: "ClassVariable",
301
+ matches: "declarations of kind ClassVariable",
302
+ description: "A class variable declaration",
303
+ },
304
+ ];
305
+
306
+ /// The node properties queries can read. Single source of truth for the `--schema` catalog; the
307
+ /// value resolution lives in [`property`] and its per-node-type helpers.
308
+ pub const NODE_PROPERTIES: &[PropertySchema] = &[
309
+ PropertySchema {
310
+ node_type: "(any)",
311
+ property: "label",
312
+ description: "The node's top-level label / kind",
313
+ },
314
+ PropertySchema {
315
+ node_type: "(any)",
316
+ property: "kind",
317
+ description: "Alias of `label`",
318
+ },
319
+ PropertySchema {
320
+ node_type: "Declaration",
321
+ property: "name",
322
+ description: "Fully qualified name",
323
+ },
324
+ PropertySchema {
325
+ node_type: "Declaration",
326
+ property: "unqualified_name",
327
+ description: "Name without its namespace prefix",
328
+ },
329
+ PropertySchema {
330
+ node_type: "Declaration",
331
+ property: "visibility",
332
+ description: "public / protected / private (when applicable)",
333
+ },
334
+ PropertySchema {
335
+ node_type: "Declaration",
336
+ property: "definition_count",
337
+ description: "Number of definitions that compose the declaration",
338
+ },
339
+ PropertySchema {
340
+ node_type: "Definition",
341
+ property: "name",
342
+ description: "Name of the declaration this definition contributes to",
343
+ },
344
+ PropertySchema {
345
+ node_type: "Definition",
346
+ property: "file",
347
+ description: "URI of the file containing the definition",
348
+ },
349
+ PropertySchema {
350
+ node_type: "Definition",
351
+ property: "line",
352
+ description: "1-indexed start line of the definition",
353
+ },
354
+ PropertySchema {
355
+ node_type: "Document",
356
+ property: "uri",
357
+ description: "Full document URI",
358
+ },
359
+ PropertySchema {
360
+ node_type: "Document",
361
+ property: "path",
362
+ description: "File system path of the document",
363
+ },
364
+ PropertySchema {
365
+ node_type: "Document",
366
+ property: "name",
367
+ description: "Base file name of the document",
368
+ },
369
+ ];
370
+
371
+ /// Exposes the rubydex [`Graph`] to the `cypher-parser` executor as a property graph. This is the
372
+ /// rubydex-specific mapping; the executor itself is generic over this trait.
373
+ impl GraphProvider for Graph {
374
+ type NodeId = NodeRef;
375
+
376
+ fn scan(&self, labels: &[String]) -> Vec<NodeRef> {
377
+ scan(self, labels)
378
+ }
379
+
380
+ fn matches_label(&self, node: NodeRef, label: &str) -> bool {
381
+ matches_label(self, node, label)
382
+ }
383
+
384
+ fn relationship_types(&self) -> Vec<String> {
385
+ RelType::all().map(|rel| rel.name().to_string()).collect()
386
+ }
387
+
388
+ fn expand(&self, node: NodeRef, rel_type: &str) -> Vec<NodeRef> {
389
+ RelType::parse(rel_type).map_or_else(Vec::new, |rel| expand_out(self, node, rel))
390
+ }
391
+
392
+ fn rel_sources(&self, rel_type: &str) -> Vec<NodeRef> {
393
+ RelType::parse(rel_type).map_or_else(Vec::new, |rel| rel_source_nodes(self, rel))
394
+ }
395
+
396
+ fn expand_in(&self, node: NodeRef, rel_type: &str) -> Option<Vec<NodeRef>> {
397
+ RelType::parse(rel_type).and_then(|rel| expand_in(self, node, rel))
398
+ }
399
+
400
+ fn property(&self, node: NodeRef, prop: &str) -> CypherValue {
401
+ property(self, node, prop)
402
+ }
403
+
404
+ fn label(&self, node: NodeRef) -> String {
405
+ node_label(self, node)
406
+ }
407
+
408
+ fn name(&self, node: NodeRef) -> String {
409
+ node_name(self, node)
410
+ }
411
+
412
+ fn node_id(&self, node: NodeRef) -> String {
413
+ // Encode the node category plus the underlying hashed id so a consumer can decode it back to
414
+ // the right handle. The category tag is required because labels alone are ambiguous
415
+ // (a class declaration and a class definition both have the label "Class").
416
+ match node {
417
+ NodeRef::Declaration(id) => format!("decl:{}", *id),
418
+ NodeRef::Definition(id) => format!("def:{}", *id),
419
+ NodeRef::Document(id) => format!("doc:{}", *id),
420
+ }
421
+ }
422
+ }
423
+
424
+ /// Returns all nodes matching the given labels. An empty slice matches every node; otherwise a node
425
+ /// is returned if it matches **any** of the labels (label disjunction, e.g. `(:Class|Module)`).
426
+ #[must_use]
427
+ pub fn scan(graph: &Graph, labels: &[String]) -> Vec<NodeRef> {
428
+ if labels.is_empty() {
429
+ let mut nodes = Vec::new();
430
+ nodes.extend(graph.documents().keys().map(|id| NodeRef::Document(*id)));
431
+ nodes.extend(graph.definitions().keys().map(|id| NodeRef::Definition(*id)));
432
+ nodes.extend(graph.declarations().keys().map(|id| NodeRef::Declaration(*id)));
433
+ return nodes;
434
+ }
435
+
436
+ let mut seen = HashSet::new();
437
+ let mut nodes = Vec::new();
438
+ for label in labels {
439
+ for node in scan_label(graph, label) {
440
+ if seen.insert(node) {
441
+ nodes.push(node);
442
+ }
443
+ }
444
+ }
445
+ nodes
446
+ }
447
+
448
+ /// Returns all nodes matching a single label.
449
+ fn scan_label(graph: &Graph, label: &str) -> Vec<NodeRef> {
450
+ match label {
451
+ "Document" => graph.documents().keys().map(|id| NodeRef::Document(*id)).collect(),
452
+ "Definition" => graph.definitions().keys().map(|id| NodeRef::Definition(*id)).collect(),
453
+ other => graph
454
+ .declarations()
455
+ .iter()
456
+ .filter(|(_, declaration)| declaration_matches_label(declaration, other))
457
+ .map(|(id, _)| NodeRef::Declaration(*id))
458
+ .collect(),
459
+ }
460
+ }
461
+
462
+ /// Returns whether a node matches a single label.
463
+ #[must_use]
464
+ pub fn matches_label(graph: &Graph, node: NodeRef, label: &str) -> bool {
465
+ match node {
466
+ NodeRef::Document(_) => label == "Document",
467
+ NodeRef::Definition(_) => label == "Definition",
468
+ NodeRef::Declaration(id) => graph
469
+ .declarations()
470
+ .get(&id)
471
+ .is_some_and(|declaration| declaration_matches_label(declaration, label)),
472
+ }
473
+ }
474
+
475
+ fn declaration_matches_label(declaration: &Declaration, label: &str) -> bool {
476
+ match label {
477
+ "Declaration" => true,
478
+ "Namespace" => declaration.as_namespace().is_some(),
479
+ other => declaration.kind() == other,
480
+ }
481
+ }
482
+
483
+ /// Returns the top-level label name of a node, used for display and JSON output.
484
+ #[must_use]
485
+ pub fn node_label(graph: &Graph, node: NodeRef) -> String {
486
+ match node {
487
+ NodeRef::Document(_) => "Document".to_string(),
488
+ NodeRef::Definition(id) => graph
489
+ .definitions()
490
+ .get(&id)
491
+ .map_or_else(|| "Definition".to_string(), |definition| definition.kind().to_string()),
492
+ NodeRef::Declaration(id) => graph.declarations().get(&id).map_or_else(
493
+ || "Declaration".to_string(),
494
+ |declaration| declaration.kind().to_string(),
495
+ ),
496
+ }
497
+ }
498
+
499
+ /// The primary display name of a node (FQN for declarations, URI basename for documents).
500
+ #[must_use]
501
+ pub fn node_name(graph: &Graph, node: NodeRef) -> String {
502
+ match node {
503
+ NodeRef::Declaration(id) => graph
504
+ .declarations()
505
+ .get(&id)
506
+ .map_or_else(String::new, |declaration| declaration.name().to_string()),
507
+ NodeRef::Definition(id) => graph
508
+ .definitions()
509
+ .get(&id)
510
+ .and_then(|definition| graph.definition_to_declaration_id(definition))
511
+ .and_then(|decl_id| graph.declarations().get(decl_id))
512
+ .map_or_else(String::new, |declaration| declaration.name().to_string()),
513
+ NodeRef::Document(id) => graph.documents().get(&id).map_or_else(String::new, |document| {
514
+ document.file_name().unwrap_or_else(|| document.uri().to_string())
515
+ }),
516
+ }
517
+ }
518
+
519
+ /// Resolves a node property to a value, where `prop` is the property name read off the node (the
520
+ /// `x` in `RETURN n.x` / `WHERE n.x = ...`). Unknown properties yield `NULL`.
521
+ #[must_use]
522
+ pub fn property(graph: &Graph, node: NodeRef, prop: &str) -> CypherValue {
523
+ match prop {
524
+ "label" | "kind" => CypherValue::Str(node_label(graph, node)),
525
+ _ => match node {
526
+ NodeRef::Declaration(id) => declaration_property(graph, id, prop),
527
+ NodeRef::Definition(id) => definition_property(graph, id, prop),
528
+ NodeRef::Document(id) => document_property(graph, id, prop),
529
+ },
530
+ }
531
+ }
532
+
533
+ fn declaration_property(graph: &Graph, id: DeclarationId, prop: &str) -> CypherValue {
534
+ let Some(declaration) = graph.declarations().get(&id) else {
535
+ return CypherValue::Null;
536
+ };
537
+
538
+ match prop {
539
+ "name" => CypherValue::Str(declaration.name().to_string()),
540
+ "unqualified_name" => CypherValue::Str(declaration.unqualified_name()),
541
+ "visibility" => graph
542
+ .visibility(&id)
543
+ .map_or(CypherValue::Null, |visibility| CypherValue::Str(visibility.to_string())),
544
+ "definition_count" => CypherValue::Int(i64::try_from(declaration.definitions().len()).unwrap_or(i64::MAX)),
545
+ _ => CypherValue::Null,
546
+ }
547
+ }
548
+
549
+ fn definition_property(graph: &Graph, id: DefinitionId, prop: &str) -> CypherValue {
550
+ let Some(definition) = graph.definitions().get(&id) else {
551
+ return CypherValue::Null;
552
+ };
553
+
554
+ match prop {
555
+ "name" => CypherValue::Str(node_name(graph, NodeRef::Definition(id))),
556
+ "file" => graph
557
+ .documents()
558
+ .get(definition.uri_id())
559
+ .map_or(CypherValue::Null, |document| {
560
+ CypherValue::Str(document.uri().to_string())
561
+ }),
562
+ "line" => graph
563
+ .documents()
564
+ .get(definition.uri_id())
565
+ .map_or(CypherValue::Null, |document| {
566
+ let location = definition.offset().to_location(document).to_presentation();
567
+ CypherValue::Int(i64::from(location.start_line()))
568
+ }),
569
+ _ => CypherValue::Null,
570
+ }
571
+ }
572
+
573
+ fn document_property(graph: &Graph, id: UriId, prop: &str) -> CypherValue {
574
+ let Some(document) = graph.documents().get(&id) else {
575
+ return CypherValue::Null;
576
+ };
577
+
578
+ // Non-`file://` URIs (the synthetic built-in document) have no file path, so `path`/`name` fall
579
+ // back to the raw URI.
580
+ match prop {
581
+ // Full document URI, e.g. `file:///app/models/user.rb`.
582
+ "uri" => CypherValue::Str(document.uri().to_string()),
583
+ // File-system path, e.g. `/app/models/user.rb`.
584
+ "path" => CypherValue::Str(document.file_path().map_or_else(
585
+ || document.uri().to_string(),
586
+ |path| path.to_string_lossy().into_owned(),
587
+ )),
588
+ // Base file name, e.g. `user.rb`.
589
+ "name" => CypherValue::Str(document.file_name().unwrap_or_else(|| document.uri().to_string())),
590
+ _ => CypherValue::Null,
591
+ }
592
+ }
593
+
594
+ /// Returns the candidate source nodes for a relationship type, used to build reverse adjacency.
595
+ #[must_use]
596
+ pub fn rel_source_nodes(graph: &Graph, rel: RelType) -> Vec<NodeRef> {
597
+ match rel {
598
+ RelType::Defines | RelType::References => graph.documents().keys().map(|id| NodeRef::Document(*id)).collect(),
599
+ RelType::Declares | RelType::Contains => {
600
+ graph.definitions().keys().map(|id| NodeRef::Definition(*id)).collect()
601
+ }
602
+ RelType::HasParent
603
+ | RelType::Includes
604
+ | RelType::Prepends
605
+ | RelType::Extends
606
+ | RelType::Owns
607
+ | RelType::HasAncestor
608
+ | RelType::HasDescendant => graph
609
+ .declarations()
610
+ .keys()
611
+ .map(|id| NodeRef::Declaration(*id))
612
+ .collect(),
613
+ }
614
+ }
615
+
616
+ /// Expands the *incoming* edges of `node` for the given relationship type — the targeted reverse of
617
+ /// [`expand_out`], returning `Some` only when the graph stores the reverse cheaply and exactly.
618
+ /// Returning `None` lets the executor fall back to `rel_sources` + `expand` (always correct, but an
619
+ /// O(all sources) whole-graph build).
620
+ ///
621
+ /// Only the Document → Definition → Declaration spine is answered directly:
622
+ /// - reverse `DEFINES`: a definition's document (each definition belongs to exactly one).
623
+ /// - reverse `DECLARES`: the per-file definitions composing a declaration.
624
+ ///
625
+ /// The remaining edges either have no stored reverse (e.g. direct subclasses of `HAS_PARENT`) or a
626
+ /// reverse that isn't an exact set match (the `HAS_DESCENDANT` set is self-inclusive, unlike the
627
+ /// `HAS_ANCESTOR` walk), so they fall through to the default.
628
+ #[must_use]
629
+ pub fn expand_in(graph: &Graph, node: NodeRef, rel: RelType) -> Option<Vec<NodeRef>> {
630
+ match (node, rel) {
631
+ (NodeRef::Definition(def_id), RelType::Defines) => {
632
+ let uri_id = *graph.definitions().get(&def_id)?.uri_id();
633
+ Some(vec![NodeRef::Document(uri_id)])
634
+ }
635
+ (NodeRef::Declaration(decl_id), RelType::Declares) => Some(
636
+ graph
637
+ .declarations()
638
+ .get(&decl_id)?
639
+ .definitions()
640
+ .iter()
641
+ .map(|id| NodeRef::Definition(*id))
642
+ .collect(),
643
+ ),
644
+ _ => None,
645
+ }
646
+ }
647
+
648
+ /// Expands the outgoing edges of `node` for the given relationship type.
649
+ #[must_use]
650
+ pub fn expand_out(graph: &Graph, node: NodeRef, rel: RelType) -> Vec<NodeRef> {
651
+ match (node, rel) {
652
+ (NodeRef::Document(uri_id), RelType::Defines) => graph
653
+ .documents()
654
+ .get(&uri_id)
655
+ .map(|document| {
656
+ document
657
+ .definitions()
658
+ .iter()
659
+ .map(|id| NodeRef::Definition(*id))
660
+ .collect()
661
+ })
662
+ .unwrap_or_default(),
663
+ (NodeRef::Document(uri_id), RelType::References) => document_references(graph, uri_id),
664
+ (NodeRef::Definition(def_id), RelType::Declares) => graph
665
+ .definitions()
666
+ .get(&def_id)
667
+ .and_then(|definition| graph.definition_to_declaration_id(definition))
668
+ .map(|decl_id| vec![NodeRef::Declaration(*decl_id)])
669
+ .unwrap_or_default(),
670
+ (NodeRef::Definition(def_id), RelType::Contains) => definition_children(graph, def_id),
671
+ (NodeRef::Declaration(decl_id), RelType::HasParent) => superclasses(graph, decl_id),
672
+ (NodeRef::Declaration(decl_id), RelType::Includes) => mixin_targets(graph, decl_id, MixinKind::Include),
673
+ (NodeRef::Declaration(decl_id), RelType::Prepends) => mixin_targets(graph, decl_id, MixinKind::Prepend),
674
+ (NodeRef::Declaration(decl_id), RelType::Extends) => mixin_targets(graph, decl_id, MixinKind::Extend),
675
+ (NodeRef::Declaration(decl_id), RelType::Owns) => members(graph, decl_id),
676
+ (NodeRef::Declaration(decl_id), RelType::HasAncestor) => ancestors(graph, decl_id),
677
+ (NodeRef::Declaration(decl_id), RelType::HasDescendant) => descendants(graph, decl_id),
678
+ _ => Vec::new(),
679
+ }
680
+ }
681
+
682
+ fn document_references(graph: &Graph, uri_id: UriId) -> Vec<NodeRef> {
683
+ let Some(document) = graph.documents().get(&uri_id) else {
684
+ return Vec::new();
685
+ };
686
+
687
+ let mut seen = HashSet::new();
688
+ let mut targets = Vec::new();
689
+ for ref_id in document.constant_references() {
690
+ if let Some(decl_id) = resolve_ref(graph, *ref_id)
691
+ && seen.insert(decl_id)
692
+ {
693
+ targets.push(NodeRef::Declaration(decl_id));
694
+ }
695
+ }
696
+ targets
697
+ }
698
+
699
+ fn definition_children(graph: &Graph, def_id: DefinitionId) -> Vec<NodeRef> {
700
+ let Some(definition) = graph.definitions().get(&def_id) else {
701
+ return Vec::new();
702
+ };
703
+
704
+ let children: &[DefinitionId] = match definition {
705
+ Definition::Class(d) => d.members(),
706
+ Definition::Module(d) => d.members(),
707
+ Definition::SingletonClass(d) => d.members(),
708
+ _ => &[],
709
+ };
710
+ children.iter().map(|id| NodeRef::Definition(*id)).collect()
711
+ }
712
+
713
+ fn superclasses(graph: &Graph, decl_id: DeclarationId) -> Vec<NodeRef> {
714
+ let Some(declaration) = graph.declarations().get(&decl_id) else {
715
+ return Vec::new();
716
+ };
717
+
718
+ let mut seen = HashSet::new();
719
+ let mut targets = Vec::new();
720
+ for definition_id in declaration.definitions() {
721
+ if let Some(Definition::Class(class_def)) = graph.definitions().get(definition_id)
722
+ && let Some(superclass_ref) = class_def.superclass_ref()
723
+ && let Some(target) = resolve_ref_to_namespace(graph, *superclass_ref)
724
+ && seen.insert(target)
725
+ {
726
+ targets.push(NodeRef::Declaration(target));
727
+ }
728
+ }
729
+ targets
730
+ }
731
+
732
+ #[derive(Clone, Copy)]
733
+ enum MixinKind {
734
+ Include,
735
+ Prepend,
736
+ Extend,
737
+ }
738
+
739
+ fn mixin_targets(graph: &Graph, decl_id: DeclarationId, kind: MixinKind) -> Vec<NodeRef> {
740
+ let Some(declaration) = graph.declarations().get(&decl_id) else {
741
+ return Vec::new();
742
+ };
743
+
744
+ let mut seen = HashSet::new();
745
+ let mut targets = Vec::new();
746
+ for definition_id in declaration.definitions() {
747
+ let mixins: &[Mixin] = match graph.definitions().get(definition_id) {
748
+ Some(Definition::Class(d)) => d.mixins(),
749
+ Some(Definition::Module(d)) => d.mixins(),
750
+ Some(Definition::SingletonClass(d)) => d.mixins(),
751
+ _ => &[],
752
+ };
753
+
754
+ for mixin in mixins {
755
+ let matches = matches!(
756
+ (kind, mixin),
757
+ (MixinKind::Include, Mixin::Include(_))
758
+ | (MixinKind::Prepend, Mixin::Prepend(_))
759
+ | (MixinKind::Extend, Mixin::Extend(_))
760
+ );
761
+ if matches
762
+ && let Some(target) = resolve_ref_to_namespace(graph, *mixin.constant_reference_id())
763
+ && seen.insert(target)
764
+ {
765
+ targets.push(NodeRef::Declaration(target));
766
+ }
767
+ }
768
+ }
769
+ targets
770
+ }
771
+
772
+ fn members(graph: &Graph, decl_id: DeclarationId) -> Vec<NodeRef> {
773
+ graph
774
+ .declarations()
775
+ .get(&decl_id)
776
+ .and_then(Declaration::as_namespace)
777
+ .map(|namespace| {
778
+ namespace
779
+ .members()
780
+ .values()
781
+ .map(|id| NodeRef::Declaration(*id))
782
+ .collect()
783
+ })
784
+ .unwrap_or_default()
785
+ }
786
+
787
+ fn ancestors(graph: &Graph, decl_id: DeclarationId) -> Vec<NodeRef> {
788
+ use crate::model::declaration::Ancestor;
789
+
790
+ graph
791
+ .declarations()
792
+ .get(&decl_id)
793
+ .and_then(Declaration::as_namespace)
794
+ .map(|namespace| {
795
+ namespace
796
+ .ancestors()
797
+ .iter()
798
+ .filter_map(|ancestor| match ancestor {
799
+ Ancestor::Complete(id) if *id != decl_id => Some(NodeRef::Declaration(*id)),
800
+ _ => None,
801
+ })
802
+ .collect()
803
+ })
804
+ .unwrap_or_default()
805
+ }
806
+
807
+ fn descendants(graph: &Graph, decl_id: DeclarationId) -> Vec<NodeRef> {
808
+ graph
809
+ .declarations()
810
+ .get(&decl_id)
811
+ .and_then(Declaration::as_namespace)
812
+ .map(|namespace| {
813
+ namespace
814
+ .descendants()
815
+ .iter()
816
+ .map(|id| NodeRef::Declaration(*id))
817
+ .collect()
818
+ })
819
+ .unwrap_or_default()
820
+ }
821
+
822
+ /// Resolves a constant reference to the declaration of the name it points to.
823
+ fn resolve_ref(graph: &Graph, ref_id: ConstantReferenceId) -> Option<DeclarationId> {
824
+ let constant_ref = graph.constant_references().get(&ref_id)?;
825
+ graph.name_id_to_declaration_id(*constant_ref.name_id()).copied()
826
+ }
827
+
828
+ /// Resolves a constant reference to a namespace declaration, following constant aliases.
829
+ fn resolve_ref_to_namespace(graph: &Graph, ref_id: ConstantReferenceId) -> Option<DeclarationId> {
830
+ resolve_to_namespace(graph, resolve_ref(graph, ref_id)?)
831
+ }
832
+
833
+ /// Walks constant-alias chains until reaching a namespace declaration.
834
+ fn resolve_to_namespace(graph: &Graph, declaration_id: DeclarationId) -> Option<DeclarationId> {
835
+ let mut queue = VecDeque::from([declaration_id]);
836
+ let mut seen = HashSet::new();
837
+
838
+ while let Some(current_id) = queue.pop_front() {
839
+ if !seen.insert(current_id) {
840
+ continue;
841
+ }
842
+
843
+ match graph.declarations().get(&current_id)? {
844
+ Declaration::Namespace(_) => return Some(current_id),
845
+ Declaration::ConstantAlias(_) => {
846
+ queue.extend(graph.alias_targets(&current_id)?);
847
+ }
848
+ _ => {}
849
+ }
850
+ }
851
+
852
+ None
853
+ }