From 5d58c118d7aae37f4cb5456ee09258e9a26880a0 Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Wed, 8 Jul 2026 20:47:45 +0300 Subject: [PATCH] Expose resolved method calls as a CALLS edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `CALLS` relationship (`Document` → `Method`) that maps method references to the method declarations they resolve to, for calls whose receiver type is statically known: - a constant receiver (`Foo.bar`), resolved through the receiver's singleton-class ancestry, and - an implicit `self` call in a known scope. The indexer already records these calls with a receiver-type name; the schema resolves that name to a declaration and looks the method up along its ancestry (matching the base name, since method members are keyed `name()` while call sites record `name`). Calls with a dynamic/unknown receiver carry no receiver and are intentionally not represented — those would require type inference. This makes call-graph queries possible, e.g. callers of a method: `MATCH (d:Document)-[:CALLS]->(m:Method {unqualified_name: 'bar()'}) RETURN d.name`. --- rust/rubydex/src/query/cypher/schema.rs | 97 ++++++++++++++++++++++++- rust/rubydex/src/query/cypher/tests.rs | 47 ++++++++++++ 2 files changed, 141 insertions(+), 3 deletions(-) diff --git a/rust/rubydex/src/query/cypher/schema.rs b/rust/rubydex/src/query/cypher/schema.rs index 8274544b4..ed5b87434 100644 --- a/rust/rubydex/src/query/cypher/schema.rs +++ b/rust/rubydex/src/query/cypher/schema.rs @@ -21,13 +21,15 @@ //! - `HAS_ANCESTOR`: `Declaration` → `Declaration` (linearized ancestor chain, incl. modules) //! - `HAS_DESCENDANT`: `Declaration` → `Declaration` (reverse of `HAS_ANCESTOR`) //! - `REFERENCES`: `Document` → `Declaration` (constant references) +//! - `CALLS`: `Document` → `Declaration` (a `Method`; a constant-receiver method call like `Foo.bar` +//! resolved to its method declaration) use std::collections::{HashSet, VecDeque}; -use crate::model::declaration::Declaration; +use crate::model::declaration::{Declaration, Namespace}; use crate::model::definitions::{Definition, Mixin}; use crate::model::graph::Graph; -use crate::model::ids::{ConstantReferenceId, DeclarationId, DefinitionId, UriId}; +use crate::model::ids::{ConstantReferenceId, DeclarationId, DefinitionId, NameId, StringId, UriId}; use cypher_parser::{CypherValue, GraphProvider}; @@ -71,6 +73,11 @@ pub enum RelType { HasDescendant, /// `Document` → `Declaration`: a constant reference in the file resolves to a declaration. References, + /// `Document` → `Declaration` (a `Method`): a method call whose receiver *type* is statically + /// known — a constant receiver (`Foo.bar`, resolved via the receiver's singleton-class ancestry) + /// or an implicit `self` call in a known scope — resolved to the method declaration. Calls with + /// a dynamic/unknown receiver carry no receiver and are not represented. + Calls, } /// Catalog metadata for a relationship type: the type itself, its canonical name, endpoint labels, @@ -164,6 +171,13 @@ const REL_SCHEMAS: &[RelSchema] = &[ to: "Declaration", description: "A file references a constant declaration", }, + RelSchema { + rel: RelType::Calls, + name: "CALLS", + from: "Document", + to: "Method", + description: "A file calls a method whose receiver type is statically known", + }, ]; impl RelType { @@ -564,7 +578,9 @@ fn document_property(graph: &Graph, id: UriId, prop: &str) -> CypherValue { #[must_use] pub fn rel_source_nodes(graph: &Graph, rel: RelType) -> Vec { match rel { - RelType::Defines | RelType::References => graph.documents().keys().map(|id| NodeRef::Document(*id)).collect(), + RelType::Defines | RelType::References | RelType::Calls => { + graph.documents().keys().map(|id| NodeRef::Document(*id)).collect() + } RelType::Declares | RelType::Contains => { graph.definitions().keys().map(|id| NodeRef::Definition(*id)).collect() } @@ -598,6 +614,7 @@ pub fn expand_out(graph: &Graph, node: NodeRef, rel: RelType) -> Vec { }) .unwrap_or_default(), (NodeRef::Document(uri_id), RelType::References) => document_references(graph, uri_id), + (NodeRef::Document(uri_id), RelType::Calls) => document_method_calls(graph, uri_id), (NodeRef::Definition(def_id), RelType::Declares) => graph .definitions() .get(&def_id) @@ -633,6 +650,80 @@ fn document_references(graph: &Graph, uri_id: UriId) -> Vec { targets } +/// The method declarations reached by the constant-receiver method calls in a document. +/// +/// Only calls whose receiver is a constant (e.g. `Foo.bar`) are statically resolvable, so calls +/// with an implicit or non-constant receiver are skipped. +fn document_method_calls(graph: &Graph, uri_id: UriId) -> Vec { + let Some(document) = graph.documents().get(&uri_id) else { + return Vec::new(); + }; + + let mut seen = HashSet::new(); + let mut targets = Vec::new(); + for ref_id in document.method_references() { + let Some(method_ref) = graph.method_references().get(ref_id) else { + continue; + }; + let Some(receiver) = method_ref.receiver() else { + continue; + }; + if let Some(decl_id) = resolve_method_call(graph, receiver, *method_ref.str()) + && seen.insert(decl_id) + { + targets.push(NodeRef::Declaration(decl_id)); + } + } + targets +} + +/// Resolves a method call to its method declaration. `receiver` is the receiver *type*'s name as +/// recorded by the indexer — the singleton class for a constant receiver (`Foo.bar`) or the +/// enclosing type for an implicit `self` call — so the method is looked up directly along that +/// declaration's ancestry, returning the first (most-derived) match. Calls whose receiver type is +/// not statically known carry no receiver and never reach here. +fn resolve_method_call(graph: &Graph, receiver: NameId, method_str: StringId) -> Option { + use crate::model::declaration::Ancestor; + + // Call sites record the bare message (`bar`), while method declarations are keyed by + // `name()` in a namespace's members, so we match on the base name rather than the `StringId`. + let call_name = graph.strings().get(&method_str)?.as_str(); + + let receiver_decl_id = *graph.name_id_to_declaration_id(receiver)?; + let receiver_decl_id = resolve_to_namespace(graph, receiver_decl_id)?; + let namespace = graph.declarations().get(&receiver_decl_id)?.as_namespace()?; + + for ancestor in namespace.ancestors() { + let Ancestor::Complete(ancestor_id) = ancestor else { + continue; + }; + let Some(members) = graph + .declarations() + .get(ancestor_id) + .and_then(Declaration::as_namespace) + .map(Namespace::members) + else { + continue; + }; + + for (key, member_id) in members { + if graph + .declarations() + .get(member_id) + .is_some_and(|d| d.as_method().is_some()) + && graph + .strings() + .get(key) + .and_then(|name| name.as_str().strip_suffix("()")) + == Some(call_name) + { + return Some(*member_id); + } + } + } + None +} + fn definition_children(graph: &Graph, def_id: DefinitionId) -> Vec { let Some(definition) = graph.definitions().get(&def_id) else { return Vec::new(); diff --git a/rust/rubydex/src/query/cypher/tests.rs b/rust/rubydex/src/query/cypher/tests.rs index 40fa8118f..8dd38e666 100644 --- a/rust/rubydex/src/query/cypher/tests.rs +++ b/rust/rubydex/src/query/cypher/tests.rs @@ -226,3 +226,50 @@ fn document_uri_path_and_name_are_distinct() { #[cfg(windows)] assert_eq!(column_strings(&result, 1), vec!["file:///zoo.rb".to_string()]); } + +fn calls_graph() -> Graph { + let mut context = GraphTest::new(); + context.index_uri( + "file:///m.rb", + " + class Foo + def self.bar; end + end + + class Client + def run + Foo.bar # constant receiver -> resolvable + helper # implicit receiver -> not resolvable + end + + def helper; end + end + ", + ); + context.resolve(); + context.into_graph() +} + +#[test] +fn calls_resolves_method_references_with_a_known_receiver() { + let graph = calls_graph(); + let result = run( + &graph, + "MATCH (:Document)-[:CALLS]->(m:Method) RETURN m.unqualified_name", + ); + // `Foo.bar` (constant receiver, class method) and `helper` (implicit self) both resolve. + assert_eq!( + column_strings(&result, 0), + vec!["bar()".to_string(), "helper()".to_string()] + ); +} + +#[test] +fn calls_reverse_finds_callers_of_a_method() { + let graph = calls_graph(); + let result = run( + &graph, + "MATCH (d:Document)-[:CALLS]->(m:Method {unqualified_name: 'bar()'}) RETURN d.name", + ); + assert_eq!(column_strings(&result, 0), vec!["m.rb".to_string()]); +}