Skip to content

Commit 4d2f41e

Browse files
committed
Automatically promote constants to namespaces
1 parent fd46f2f commit 4d2f41e

6 files changed

Lines changed: 752 additions & 108 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(
@@ -1297,7 +1317,7 @@ impl Visit<'_> for RubyIndexer<'_> {
12971317
if let Some(target_name_id) = self.index_constant_alias_target(&node.value()) {
12981318
self.add_constant_alias_definition(&node.as_node(), target_name_id, true);
12991319
} else {
1300-
self.add_constant_definition(&node.as_node(), true);
1320+
self.add_constant_definition(&node.as_node(), true, Self::is_promotable_value(&node.value()));
13011321
self.visit(&node.value());
13021322
}
13031323
}
@@ -1311,7 +1331,7 @@ impl Visit<'_> for RubyIndexer<'_> {
13111331
if let Some(target_name_id) = self.index_constant_alias_target(&value) {
13121332
self.add_constant_alias_definition(&node.as_node(), target_name_id, false);
13131333
} else {
1314-
self.add_constant_definition(&node.as_node(), false);
1334+
self.add_constant_definition(&node.as_node(), false, Self::is_promotable_value(&value));
13151335
self.visit(&value);
13161336
}
13171337
}
@@ -1330,7 +1350,7 @@ impl Visit<'_> for RubyIndexer<'_> {
13301350
if let Some(target_name_id) = self.index_constant_alias_target(&node.value()) {
13311351
self.add_constant_alias_definition(&node.target().as_node(), target_name_id, true);
13321352
} else {
1333-
self.add_constant_definition(&node.target().as_node(), true);
1353+
self.add_constant_definition(&node.target().as_node(), true, Self::is_promotable_value(&node.value()));
13341354
self.visit(&node.value());
13351355
}
13361356
}
@@ -1344,7 +1364,7 @@ impl Visit<'_> for RubyIndexer<'_> {
13441364
if let Some(target_name_id) = self.index_constant_alias_target(&value) {
13451365
self.add_constant_alias_definition(&node.target().as_node(), target_name_id, false);
13461366
} else {
1347-
self.add_constant_definition(&node.target().as_node(), false);
1367+
self.add_constant_definition(&node.target().as_node(), false, Self::is_promotable_value(&value));
13481368
self.visit(&value);
13491369
}
13501370
}
@@ -1361,7 +1381,10 @@ impl Visit<'_> for RubyIndexer<'_> {
13611381
for left in &node.lefts() {
13621382
match left {
13631383
ruby_prism::Node::ConstantTargetNode { .. } | ruby_prism::Node::ConstantPathTargetNode { .. } => {
1364-
self.add_constant_definition(&left, false);
1384+
// Individual values aren't available in multi-write, so we default to
1385+
// promotable because multi-assignment often comes from meta-programming
1386+
// (e.g., `A, B = create_classes`).
1387+
self.add_constant_definition(&left, false, true);
13651388
}
13661389
ruby_prism::Node::GlobalVariableTargetNode { .. } => {
13671390
self.add_definition_from_location(
@@ -5887,4 +5910,52 @@ mod tests {
58875910
// We expect two constant references for `Foo` and `<Foo>` on each singleton method call
58885911
assert_eq!(6, context.graph().constant_references().len());
58895912
}
5913+
5914+
#[test]
5915+
fn constant_with_call_value_is_promotable() {
5916+
let context = index_source("Foo = some_call");
5917+
5918+
assert_definition_at!(&context, "1:1-1:4", Constant, |def| {
5919+
assert!(
5920+
def.flags().is_promotable(),
5921+
"Expected PROMOTABLE flag to be set for call value"
5922+
);
5923+
});
5924+
}
5925+
5926+
#[test]
5927+
fn constant_with_literal_value_is_not_promotable() {
5928+
let context = index_source("FOO = 42");
5929+
5930+
assert_definition_at!(&context, "1:1-1:4", Constant, |def| {
5931+
assert!(
5932+
!def.flags().is_promotable(),
5933+
"Expected PROMOTABLE flag to NOT be set for literal value"
5934+
);
5935+
});
5936+
}
5937+
5938+
#[test]
5939+
fn constant_with_operator_call_is_not_promotable() {
5940+
let context = index_source("FOO = 1 + 2");
5941+
5942+
assert_definition_at!(&context, "1:1-1:4", Constant, |def| {
5943+
assert!(
5944+
!def.flags().is_promotable(),
5945+
"Expected PROMOTABLE flag to NOT be set for operator call"
5946+
);
5947+
});
5948+
}
5949+
5950+
#[test]
5951+
fn constant_with_dot_call_is_promotable() {
5952+
let context = index_source("Foo = Bar.new");
5953+
5954+
assert_definition_at!(&context, "1:1-1:4", Constant, |def| {
5955+
assert!(
5956+
def.flags().is_promotable(),
5957+
"Expected PROMOTABLE flag to be set for dot call"
5958+
);
5959+
});
5960+
}
58905961
}

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: 67 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,82 @@ 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+
155+
#[must_use]
156+
pub fn is_namespace(&self, declaration_id: &DeclarationId) -> bool {
157+
self.declarations
158+
.get(declaration_id)
159+
.is_some_and(|decl| decl.as_namespace().is_some())
160+
}
161+
101162
pub fn clear_declarations(&mut self) {
102163
self.declarations.clear();
103164
}
@@ -502,9 +563,6 @@ impl Graph {
502563
Declaration::Namespace(Namespace::SingletonClass(it)) => {
503564
it.add_member(member_str_id, member_declaration_id);
504565
}
505-
Declaration::Constant(_) => {
506-
// TODO: temporary hack to avoid crashing on `Struct.new`, `Class.new` and `Module.new`
507-
}
508566
_ => panic!("Tried to add member to a declaration that isn't a namespace"),
509567
}
510568
}

0 commit comments

Comments
 (0)