Skip to content

Commit 4c602d8

Browse files
committed
Automatically promote constants to namespaces
1 parent 77d639f commit 4c602d8

6 files changed

Lines changed: 738 additions & 101 deletions

File tree

rust/rubydex/src/indexing/ruby_indexer.rs

Lines changed: 78 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -571,7 +571,24 @@ impl<'a> RubyIndexer<'a> {
571571
definition_id
572572
}
573573

574-
fn add_constant_definition(&mut self, node: &ruby_prism::Node, also_add_reference: bool) -> Option<DefinitionId> {
574+
/// Returns whether a value node represents a method call that could produce a class or module.
575+
/// Only regular method calls (bare calls or dot/safe-nav calls) are considered promotable.
576+
/// Operator calls like `1 + 2` are `CallNode`s in Prism but should not be promotable.
577+
fn is_promotable_value(value: &ruby_prism::Node) -> bool {
578+
value.as_call_node().is_some_and(|call| {
579+
// Bare calls (no receiver): `some_factory_call`
580+
// Dot/safe-nav/scope calls: `Struct.new(...)`, `foo&.bar`, `Struct::new`
581+
// Excluded: operator calls like `1 + 2` which have a receiver but no call operator
582+
call.receiver().is_none() || call.call_operator_loc().is_some()
583+
})
584+
}
585+
586+
fn add_constant_definition(
587+
&mut self,
588+
node: &ruby_prism::Node,
589+
also_add_reference: bool,
590+
promotable: bool,
591+
) -> Option<DefinitionId> {
575592
let name_id = self.index_constant_reference(node, also_add_reference)?;
576593

577594
// Get the location for the constant name/path only (not including the value)
@@ -583,7 +600,10 @@ impl<'a> RubyIndexer<'a> {
583600
};
584601

585602
let offset = Offset::from_prism_location(&location);
586-
let (comments, flags) = self.find_comments_for(offset.start());
603+
let (comments, mut flags) = self.find_comments_for(offset.start());
604+
if promotable {
605+
flags |= DefinitionFlags::PROMOTABLE;
606+
}
587607
let lexical_nesting_id = self.parent_lexical_scope_id();
588608

589609
let definition = Definition::Constant(Box::new(ConstantDefinition::new(
@@ -1289,7 +1309,7 @@ impl Visit<'_> for RubyIndexer<'_> {
12891309
if let Some(target_name_id) = self.index_constant_alias_target(&node.value()) {
12901310
self.add_constant_alias_definition(&node.as_node(), target_name_id, true);
12911311
} else {
1292-
self.add_constant_definition(&node.as_node(), true);
1312+
self.add_constant_definition(&node.as_node(), true, Self::is_promotable_value(&node.value()));
12931313
self.visit(&node.value());
12941314
}
12951315
}
@@ -1303,7 +1323,7 @@ impl Visit<'_> for RubyIndexer<'_> {
13031323
if let Some(target_name_id) = self.index_constant_alias_target(&value) {
13041324
self.add_constant_alias_definition(&node.as_node(), target_name_id, false);
13051325
} else {
1306-
self.add_constant_definition(&node.as_node(), false);
1326+
self.add_constant_definition(&node.as_node(), false, Self::is_promotable_value(&value));
13071327
self.visit(&value);
13081328
}
13091329
}
@@ -1322,7 +1342,7 @@ impl Visit<'_> for RubyIndexer<'_> {
13221342
if let Some(target_name_id) = self.index_constant_alias_target(&node.value()) {
13231343
self.add_constant_alias_definition(&node.target().as_node(), target_name_id, true);
13241344
} else {
1325-
self.add_constant_definition(&node.target().as_node(), true);
1345+
self.add_constant_definition(&node.target().as_node(), true, Self::is_promotable_value(&node.value()));
13261346
self.visit(&node.value());
13271347
}
13281348
}
@@ -1336,7 +1356,7 @@ impl Visit<'_> for RubyIndexer<'_> {
13361356
if let Some(target_name_id) = self.index_constant_alias_target(&value) {
13371357
self.add_constant_alias_definition(&node.target().as_node(), target_name_id, false);
13381358
} else {
1339-
self.add_constant_definition(&node.target().as_node(), false);
1359+
self.add_constant_definition(&node.target().as_node(), false, Self::is_promotable_value(&value));
13401360
self.visit(&value);
13411361
}
13421362
}
@@ -1353,7 +1373,10 @@ impl Visit<'_> for RubyIndexer<'_> {
13531373
for left in &node.lefts() {
13541374
match left {
13551375
ruby_prism::Node::ConstantTargetNode { .. } | ruby_prism::Node::ConstantPathTargetNode { .. } => {
1356-
self.add_constant_definition(&left, false);
1376+
// Individual values aren't available in multi-write, so we default to
1377+
// promotable because multi-assignment often comes from meta-programming
1378+
// (e.g., `A, B = create_classes`).
1379+
self.add_constant_definition(&left, false, true);
13571380
}
13581381
ruby_prism::Node::GlobalVariableTargetNode { .. } => {
13591382
self.add_definition_from_location(
@@ -6037,4 +6060,52 @@ mod tests {
60376060
// We expect two constant references for `Foo` and `<Foo>` on each singleton method call
60386061
assert_eq!(6, context.graph().constant_references().len());
60396062
}
6063+
6064+
#[test]
6065+
fn constant_with_call_value_is_promotable() {
6066+
let context = index_source("Foo = some_call");
6067+
6068+
assert_definition_at!(&context, "1:1-1:4", Constant, |def| {
6069+
assert!(
6070+
def.flags().is_promotable(),
6071+
"Expected PROMOTABLE flag to be set for call value"
6072+
);
6073+
});
6074+
}
6075+
6076+
#[test]
6077+
fn constant_with_literal_value_is_not_promotable() {
6078+
let context = index_source("FOO = 42");
6079+
6080+
assert_definition_at!(&context, "1:1-1:4", Constant, |def| {
6081+
assert!(
6082+
!def.flags().is_promotable(),
6083+
"Expected PROMOTABLE flag to NOT be set for literal value"
6084+
);
6085+
});
6086+
}
6087+
6088+
#[test]
6089+
fn constant_with_operator_call_is_not_promotable() {
6090+
let context = index_source("FOO = 1 + 2");
6091+
6092+
assert_definition_at!(&context, "1:1-1:4", Constant, |def| {
6093+
assert!(
6094+
!def.flags().is_promotable(),
6095+
"Expected PROMOTABLE flag to NOT be set for operator call"
6096+
);
6097+
});
6098+
}
6099+
6100+
#[test]
6101+
fn constant_with_dot_call_is_promotable() {
6102+
let context = index_source("Foo = Bar.new");
6103+
6104+
assert_definition_at!(&context, "1:1-1:4", Constant, |def| {
6105+
assert!(
6106+
def.flags().is_promotable(),
6107+
"Expected PROMOTABLE flag to be set for dot call"
6108+
);
6109+
});
6110+
}
60406111
}

rust/rubydex/src/model/declaration.rs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,11 +125,14 @@ macro_rules! namespace_declaration {
125125
}
126126
}
127127

128-
pub fn extend(&mut self, mut other: Namespace) {
128+
pub fn extend(&mut self, mut other: Declaration) {
129129
self.definition_ids.extend(other.definitions());
130130
self.references.extend(other.references());
131-
self.members.extend(other.members());
132131
self.diagnostics.extend(other.take_diagnostics());
132+
133+
if let Declaration::Namespace(namespace) = other {
134+
self.members.extend(namespace.members());
135+
}
133136
}
134137

135138
pub fn set_singleton_class_id(&mut self, declaration_id: DeclarationId) {
@@ -412,6 +415,10 @@ impl Namespace {
412415
all_namespaces!(self, it => std::mem::take(&mut it.diagnostics))
413416
}
414417

418+
pub fn extend(&mut self, other: Declaration) {
419+
all_namespaces!(self, it => it.extend(other));
420+
}
421+
415422
#[must_use]
416423
pub fn ancestors(&self) -> &Ancestors {
417424
all_namespaces!(self, it => it.ancestors())
@@ -484,6 +491,11 @@ impl Namespace {
484491
pub fn owner_id(&self) -> &DeclarationId {
485492
all_namespaces!(self, it => &it.owner_id)
486493
}
494+
495+
#[must_use]
496+
pub fn name(&self) -> &str {
497+
all_namespaces!(self, it => &it.name)
498+
}
487499
}
488500

489501
namespace_declaration!(Class, ClassDeclaration);

rust/rubydex/src/model/definitions.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ bitflags! {
3939
#[derive(Debug, Clone)]
4040
pub struct DefinitionFlags: u8 {
4141
const DEPRECATED = 0b0001;
42+
const PROMOTABLE = 0b0010;
4243
}
4344
}
4445

@@ -47,6 +48,11 @@ impl DefinitionFlags {
4748
pub fn is_deprecated(&self) -> bool {
4849
self.contains(Self::DEPRECATED)
4950
}
51+
52+
#[must_use]
53+
pub fn is_promotable(&self) -> bool {
54+
self.contains(Self::PROMOTABLE)
55+
}
5056
}
5157

5258
#[derive(Debug)]

rust/rubydex/src/model/graph.rs

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ impl Graph {
6969
&mut self.declarations
7070
}
7171

72+
/// # Panics
73+
///
74+
/// Will panic if the `definition_id` is not registered in the graph
7275
pub fn add_declaration<F>(
7376
&mut self,
7477
definition_id: DefinitionId,
@@ -80,24 +83,75 @@ impl Graph {
8083
{
8184
let declaration_id = DeclarationId::from(&fully_qualified_name);
8285

86+
let should_promote = self.declarations.get(&declaration_id).is_some_and(|existing| {
87+
matches!(existing, Declaration::Constant(_))
88+
&& matches!(
89+
self.definitions.get(&definition_id),
90+
Some(Definition::Class(_) | Definition::Module(_) | Definition::SingletonClass(_))
91+
)
92+
&& self.all_definitions_promotable(existing)
93+
});
94+
8395
match self.declarations.entry(declaration_id) {
84-
Entry::Vacant(vacant_entry) => {
85-
vacant_entry
86-
.insert(constructor(fully_qualified_name))
87-
.add_definition(definition_id);
88-
}
8996
Entry::Occupied(mut occupied_entry) => {
9097
debug_assert!(
9198
occupied_entry.get().name() == fully_qualified_name,
9299
"DeclarationId collision in global graph"
93100
);
94-
occupied_entry.get_mut().add_definition(definition_id);
101+
102+
if should_promote {
103+
let mut new_declaration = constructor(fully_qualified_name);
104+
let removed_declaration = occupied_entry.remove();
105+
new_declaration.as_namespace_mut().unwrap().extend(removed_declaration);
106+
new_declaration.add_definition(definition_id);
107+
self.declarations.insert(declaration_id, new_declaration);
108+
} else {
109+
occupied_entry.get_mut().add_definition(definition_id);
110+
}
111+
}
112+
Entry::Vacant(vacant_entry) => {
113+
let mut declaration = constructor(fully_qualified_name);
114+
declaration.add_definition(definition_id);
115+
vacant_entry.insert(declaration);
95116
}
96117
}
97118

98119
declaration_id
99120
}
100121

122+
/// Checks if all constant definitions for a declaration have the PROMOTABLE flag set.
123+
/// Used to determine whether a constant can be promoted to a namespace.
124+
#[must_use]
125+
pub fn all_definitions_promotable(&self, declaration: &Declaration) -> bool {
126+
declaration
127+
.definitions()
128+
.iter()
129+
.all(|def_id| match self.definitions.get(def_id) {
130+
Some(Definition::Constant(c)) => c.flags().is_promotable(),
131+
_ => true,
132+
})
133+
}
134+
135+
/// Promotes a `Declaration::Constant` to a namespace using the provided constructor. Transfers all definitions,
136+
/// references, and diagnostics from the old declaration.
137+
///
138+
/// # Panics
139+
///
140+
/// Will panic if the declaration ID doesn't exist
141+
pub fn promote_constant_to_namespace<F>(&mut self, declaration_id: DeclarationId, constructor: F)
142+
where
143+
F: FnOnce(String, DeclarationId) -> Declaration,
144+
{
145+
let old_decl = self.declarations.remove(&declaration_id).unwrap();
146+
let name = old_decl.name().to_string();
147+
let owner_id = *old_decl.owner_id();
148+
149+
let mut new_decl = constructor(name, owner_id);
150+
new_decl.as_namespace_mut().unwrap().extend(old_decl);
151+
152+
self.declarations.insert(declaration_id, new_decl);
153+
}
154+
101155
pub fn clear_declarations(&mut self) {
102156
self.declarations.clear();
103157
}
@@ -502,9 +556,6 @@ impl Graph {
502556
Declaration::Namespace(Namespace::SingletonClass(it)) => {
503557
it.add_member(member_str_id, member_declaration_id);
504558
}
505-
Declaration::Constant(_) => {
506-
// TODO: temporary hack to avoid crashing on `Struct.new`, `Class.new` and `Module.new`
507-
}
508559
_ => panic!("Tried to add member to a declaration that isn't a namespace"),
509560
}
510561
}

0 commit comments

Comments
 (0)