Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 94 additions & 3 deletions rust/rubydex/src/query/cypher/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<NodeRef> {
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()
}
Expand Down Expand Up @@ -598,6 +614,7 @@ pub fn expand_out(graph: &Graph, node: NodeRef, rel: RelType) -> Vec<NodeRef> {
})
.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)
Expand Down Expand Up @@ -633,6 +650,80 @@ fn document_references(graph: &Graph, uri_id: UriId) -> Vec<NodeRef> {
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like we do handle calls on implicit receivers? 🤔

/// with an implicit or non-constant receiver are skipped.
fn document_method_calls(graph: &Graph, uri_id: UriId) -> Vec<NodeRef> {
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() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that we add a few synthetic method references:

match message.as_str() {
">" | "<" | ">=" | "<=" => {
self.index_method_reference("<=>".to_string(), &node.message_loc().unwrap(), method_receiver);
}

You may want to add a test showing the behavior:

class Foo
  def self.>(other); true; end
  def self.<=>(other); 0; end
end

Foo > 1

Observed Cypher result:

["<=>()", ">()"]

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<DeclarationId> {
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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we encounter partial ancestor here, we should exit the loop instead of continuing. Otherwise we could be returning the incorrect target.

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<NodeRef> {
let Some(definition) = graph.definitions().get(&def_id) else {
return Vec::new();
Expand Down
47 changes: 47 additions & 0 deletions rust/rubydex/src/query/cypher/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test seems to resolve it?

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()]);
}
Loading