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