From 1402254c978394fb7497f6270161bc9b4b475a44 Mon Sep 17 00:00:00 2001 From: Stan Lo Date: Thu, 22 Jan 2026 12:56:55 +0000 Subject: [PATCH 1/4] Capture DSL definitions and support Class|Module.new --- rust/rubydex-sys/src/definition_api.rs | 6 + rust/rubydex-sys/src/graph_api.rs | 1 + rust/rubydex/src/indexing.rs | 13 +- rust/rubydex/src/indexing/ruby_indexer.rs | 1166 ++++++++++------ rust/rubydex/src/model.rs | 2 + rust/rubydex/src/model/definitions.rs | 411 +++++- rust/rubydex/src/model/dsl.rs | 114 ++ rust/rubydex/src/model/dsl_processors.rs | 224 +++ rust/rubydex/src/model/graph.rs | 190 ++- rust/rubydex/src/resolution.rs | 1229 ++++++++++++++--- rust/rubydex/src/test_utils/graph_test.rs | 7 +- .../src/test_utils/local_graph_test.rs | 5 +- 12 files changed, 2690 insertions(+), 678 deletions(-) create mode 100644 rust/rubydex/src/model/dsl.rs create mode 100644 rust/rubydex/src/model/dsl_processors.rs diff --git a/rust/rubydex-sys/src/definition_api.rs b/rust/rubydex-sys/src/definition_api.rs index a159ff29d..6defbc0a9 100644 --- a/rust/rubydex-sys/src/definition_api.rs +++ b/rust/rubydex-sys/src/definition_api.rs @@ -26,6 +26,9 @@ pub enum DefinitionKind { ClassVariable = 11, MethodAlias = 12, GlobalVariableAlias = 13, + Dsl = 14, + DynamicClass = 15, + DynamicModule = 16, } pub(crate) fn map_definition_to_kind(defn: &Definition) -> DefinitionKind { @@ -44,6 +47,9 @@ pub(crate) fn map_definition_to_kind(defn: &Definition) -> DefinitionKind { Definition::ClassVariable(_) => DefinitionKind::ClassVariable, Definition::MethodAlias(_) => DefinitionKind::MethodAlias, Definition::GlobalVariableAlias(_) => DefinitionKind::GlobalVariableAlias, + Definition::Dsl(_) => DefinitionKind::Dsl, + Definition::DynamicClass(_) => DefinitionKind::DynamicClass, + Definition::DynamicModule(_) => DefinitionKind::DynamicModule, } } diff --git a/rust/rubydex-sys/src/graph_api.rs b/rust/rubydex-sys/src/graph_api.rs index 2f4dae1fe..34f5a3c71 100644 --- a/rust/rubydex-sys/src/graph_api.rs +++ b/rust/rubydex-sys/src/graph_api.rs @@ -458,6 +458,7 @@ mod tests { BAR = 1 end ", + vec![], ); indexer.index(); diff --git a/rust/rubydex/src/indexing.rs b/rust/rubydex/src/indexing.rs index 3f01678b3..c45007dfe 100644 --- a/rust/rubydex/src/indexing.rs +++ b/rust/rubydex/src/indexing.rs @@ -16,15 +16,22 @@ pub struct IndexingRubyFileJob { path: PathBuf, local_graph_tx: Sender, errors_tx: Sender, + dsl_method_names: Vec<&'static str>, } impl IndexingRubyFileJob { #[must_use] - pub fn new(path: PathBuf, local_graph_tx: Sender, errors_tx: Sender) -> Self { + pub fn new( + path: PathBuf, + local_graph_tx: Sender, + errors_tx: Sender, + dsl_method_names: Vec<&'static str>, + ) -> Self { Self { path, local_graph_tx, errors_tx, + dsl_method_names, } } @@ -55,7 +62,7 @@ impl Job for IndexingRubyFileJob { return; }; - let mut ruby_indexer = RubyIndexer::new(url.to_string(), &source); + let mut ruby_indexer = RubyIndexer::new(url.to_string(), &source, self.dsl_method_names.clone()); ruby_indexer.index(); self.local_graph_tx @@ -73,12 +80,14 @@ pub fn index_files(graph: &mut Graph, paths: Vec) -> Vec { let queue = Arc::new(JobQueue::new()); let (local_graphs_tx, local_graphs_rx) = unbounded(); let (errors_tx, errors_rx) = unbounded(); + let dsl_method_names = graph.dsl_method_names(); for path in paths { queue.push(Box::new(IndexingRubyFileJob::new( path, local_graphs_tx.clone(), errors_tx.clone(), + dsl_method_names.clone(), ))); } diff --git a/rust/rubydex/src/indexing/ruby_indexer.rs b/rust/rubydex/src/indexing/ruby_indexer.rs index 9f1d630bd..2716a42cc 100644 --- a/rust/rubydex/src/indexing/ruby_indexer.rs +++ b/rust/rubydex/src/indexing/ruby_indexer.rs @@ -5,13 +5,14 @@ use crate::indexing::local_graph::LocalGraph; use crate::model::comment::Comment; use crate::model::definitions::{ AttrAccessorDefinition, AttrReaderDefinition, AttrWriterDefinition, ClassDefinition, ClassVariableDefinition, - ConstantAliasDefinition, ConstantDefinition, Definition, DefinitionFlags, ExtendDefinition, + ConstantAliasDefinition, ConstantDefinition, Definition, DefinitionFlags, DslDefinition, ExtendDefinition, GlobalVariableAliasDefinition, GlobalVariableDefinition, IncludeDefinition, InstanceVariableDefinition, MethodAliasDefinition, MethodDefinition, Mixin, ModuleDefinition, Parameter, ParameterStruct, PrependDefinition, - SingletonClassDefinition, + Receiver, SingletonClassDefinition, }; use crate::model::document::Document; -use crate::model::ids::{DefinitionId, NameId, StringId, UriId}; +use crate::model::dsl::{DslArgument, DslArgumentList, DslValue}; +use crate::model::ids::{DefinitionId, NameId, ReferenceId, StringId, UriId}; use crate::model::name::{Name, ParentScope}; use crate::model::references::{ConstantReference, MethodRef}; use crate::model::visibility::Visibility; @@ -30,17 +31,17 @@ enum Nesting { /// Nesting stack entries that produce a new lexical scope to which constant references must be attached to (i.e.: /// the class and module keywords). All lexical scopes are also owner, but the opposite is not true LexicalScope(DefinitionId), - /// An owner entry that will be associated with all members encountered, but will not produce a new lexical scope - /// (e.g.: Module.new or Class.new) - Owner(DefinitionId), /// A method entry that is used to set the correct owner for instance variables, but cannot own anything itself Method(DefinitionId), + /// A DSL definition entry that owns its block contents. Members defined inside become members of the DSL. + /// DSL doesn't create a Ruby lexical scope. + Dsl(DefinitionId), } impl Nesting { fn id(&self) -> DefinitionId { match self { - Nesting::LexicalScope(id) | Nesting::Owner(id) | Nesting::Method(id) => *id, + Nesting::LexicalScope(id) | Nesting::Method(id) | Nesting::Dsl(id) => *id, } } } @@ -77,6 +78,16 @@ impl VisibilityModifier { } } +/// Gets the location for just the constant name (not including the namespace or value). +fn constant_name_location<'a>(node: &'a ruby_prism::Node<'a>) -> ruby_prism::Location<'a> { + match node { + ruby_prism::Node::ConstantWriteNode { .. } => node.as_constant_write_node().unwrap().name_loc(), + ruby_prism::Node::ConstantOrWriteNode { .. } => node.as_constant_or_write_node().unwrap().name_loc(), + ruby_prism::Node::ConstantPathNode { .. } => node.as_constant_path_node().unwrap().name_loc(), + _ => node.location(), + } +} + /// The indexer for the definitions found in the Ruby source code. /// /// It implements the `Visit` trait from `ruby_prism` to visit the AST and create a hash of definitions that must be @@ -88,11 +99,13 @@ pub struct RubyIndexer<'a> { comments: Vec, nesting_stack: Vec, visibility_stack: Vec, + /// DSL method names to capture as DSL definitions. + dsl_method_names: Vec<&'static str>, } impl<'a> RubyIndexer<'a> { #[must_use] - pub fn new(uri: String, source: &'a str) -> Self { + pub fn new(uri: String, source: &'a str, dsl_method_names: Vec<&'static str>) -> Self { let uri_id = UriId::from(&uri); let local_graph = LocalGraph::new(uri_id, Document::new(uri, source)); @@ -103,6 +116,32 @@ impl<'a> RubyIndexer<'a> { comments: Vec::new(), nesting_stack: Vec::new(), visibility_stack: vec![VisibilityModifier::new(Visibility::Private, false, Offset::new(0, 0))], + dsl_method_names, + } + } + + /// Checks if a method call matches a DSL target by method name. + fn is_dsl_target(&self, method_name: &str) -> bool { + self.dsl_method_names.contains(&method_name) + } + + /// Gets the `NameId` for a chained constant assignment (e.g., for `A = B = ...`, returns B's `NameId`). + fn get_chained_constant_name(&mut self, value: &ruby_prism::Node) -> Option { + match value { + ruby_prism::Node::ConstantWriteNode { .. } => { + self.index_constant_reference(&value.as_constant_write_node().unwrap().as_node(), false) + } + ruby_prism::Node::ConstantOrWriteNode { .. } => { + self.index_constant_reference(&value.as_constant_or_write_node().unwrap().as_node(), true) + } + ruby_prism::Node::ConstantPathWriteNode { .. } => { + self.index_constant_reference(&value.as_constant_path_write_node().unwrap().target().as_node(), false) + } + ruby_prism::Node::ConstantPathOrWriteNode { .. } => self.index_constant_reference( + &value.as_constant_path_or_write_node().unwrap().target().as_node(), + true, + ), + _ => None, } } @@ -158,6 +197,91 @@ impl<'a> RubyIndexer<'a> { String::from_utf8_lossy(location.as_slice()).to_string() } + /// Parses arguments from a [`CallNode`] into a [`DslArgumentList`]. + /// Constant references are indexed and stored as `ReferenceId`, other values as strings. + fn parse_dsl_arguments(&mut self, call: &ruby_prism::CallNode) -> DslArgumentList { + let mut args = DslArgumentList::new(); + + if let Some(arguments) = call.arguments() { + for argument in &arguments.arguments() { + match argument { + ruby_prism::Node::SplatNode { .. } => { + let splat = argument.as_splat_node().unwrap(); + if let Some(expr) = splat.expression() { + let text = Self::location_to_string(&expr.location()); + args.add(DslArgument::Splat(text)); + } + } + ruby_prism::Node::KeywordHashNode { .. } => { + let hash = argument.as_keyword_hash_node().unwrap(); + for element in &hash.elements() { + match element { + ruby_prism::Node::AssocNode { .. } => { + let assoc = element.as_assoc_node().unwrap(); + let key = Self::location_to_string(&assoc.key().location()); + let key = key.trim_end_matches(':').to_string(); + let value_node = assoc.value(); + let value = self.parse_dsl_value(&value_node); + args.add(DslArgument::KeywordArg { key, value }); + } + ruby_prism::Node::AssocSplatNode { .. } => { + let splat = element.as_assoc_splat_node().unwrap(); + if let Some(expr) = splat.value() { + let text = Self::location_to_string(&expr.location()); + args.add(DslArgument::DoubleSplat(text)); + } + } + _ => {} + } + } + } + ruby_prism::Node::BlockArgumentNode { .. } => { + let block_arg = argument.as_block_argument_node().unwrap(); + if let Some(expr) = block_arg.expression() { + let text = Self::location_to_string(&expr.location()); + args.add(DslArgument::BlockArg(text)); + } + } + _ => { + let value = self.parse_dsl_value(&argument); + args.add(DslArgument::Positional(value)); + } + } + } + } + + if let Some(block) = call.block() { + args.set_block_offset(Offset::from_prism_location(&block.location())); + } + + args + } + + /// Parses a DSL value node into a `DslValue`. + /// Constant references are indexed and stored as `ReferenceId`, other values as strings. + fn parse_dsl_value(&mut self, node: &ruby_prism::Node) -> DslValue { + match node { + ruby_prism::Node::ConstantReadNode { .. } | ruby_prism::Node::ConstantPathNode { .. } => { + // Index the constant reference and store the ReferenceId + if let Some(ref_id) = self.index_constant_reference_for_dsl(node) { + DslValue::Reference(ref_id) + } else { + // Fallback to string if indexing fails + DslValue::String(Self::location_to_string(&node.location())) + } + } + _ => DslValue::String(Self::location_to_string(&node.location())), + } + } + + /// Indexes a constant reference for use in DSL arguments. + fn index_constant_reference_for_dsl(&mut self, node: &ruby_prism::Node) -> Option { + let name_id = self.index_constant_reference(node, false)?; + let offset = Offset::from_prism_location(&constant_name_location(node)); + let constant_ref = ConstantReference::new(name_id, self.uri_id, offset); + Some(self.local_graph.add_constant_reference(constant_ref)) + } + fn find_comments_for(&self, offset: u32) -> (Vec, DefinitionFlags) { let offset_usize = offset as usize; if self.comments.is_empty() { @@ -310,14 +434,7 @@ impl<'a> RubyIndexer<'a> { } /// Gets the `NameId` of the current lexical scope (class/module/singleton class). - /// Used to resolve `self` to a concrete `NameId` during indexing. - /// - /// Iterates through the definitions stack in reverse to find the first class/module/singleton class, skipping - /// methods. Ignores `Class.new` and other owners that do not produce lexical scopes - /// - /// # Panics - /// - /// Panics if the definition is not a class, module, or singleton class + /// DSL blocks are skipped - use `current_owner_name_id()` for resolving `self` in DSL blocks. fn current_lexical_scope_name_id(&self) -> Option { self.nesting_stack.iter().rev().find_map(|nesting| match nesting { Nesting::LexicalScope(id) => { @@ -326,37 +443,48 @@ impl<'a> RubyIndexer<'a> { Definition::Class(class_def) => Some(*class_def.name_id()), Definition::Module(module_def) => Some(*module_def.name_id()), Definition::SingletonClass(singleton_class_def) => Some(*singleton_class_def.name_id()), - Definition::Method(_) => None, + Definition::Method(_) | Definition::Dsl(_) => None, _ => panic!("current nesting is not a class/module/singleton class: {definition:?}"), } } else { None } } - Nesting::Method(_) | Nesting::Owner(_) => None, + Nesting::Dsl(_) | Nesting::Method(_) => None, }) } - /// Gets the `NameId` of the current owner (class/module/singleton class), including `Class.new`/`Module.new`. + /// Gets the `NameId` of the current owner (class/module/singleton class), including DSL blocks. /// Used to resolve `self` in singleton method definitions (e.g., `def self.bar`). /// - /// Unlike `current_lexical_scope_name_id`, this method considers `Nesting::Owner` entries, + /// Unlike `current_lexical_scope_name_id`, this method considers `Nesting::Dsl` entries, /// because `self` inside a `Class.new` block refers to the new class being created. fn current_owner_name_id(&self) -> Option { self.nesting_stack.iter().rev().find_map(|nesting| match nesting { - Nesting::LexicalScope(id) | Nesting::Owner(id) => { + Nesting::LexicalScope(id) => { if let Some(definition) = self.local_graph.definitions().get(id) { match definition { Definition::Class(class_def) => Some(*class_def.name_id()), Definition::Module(module_def) => Some(*module_def.name_id()), Definition::SingletonClass(singleton_class_def) => Some(*singleton_class_def.name_id()), - Definition::Method(_) => None, + Definition::Method(_) | Definition::Dsl(_) => None, _ => panic!("current nesting is not a class/module/singleton class: {definition:?}"), } } else { None } } + Nesting::Dsl(id) => { + // For DSL definitions assigned to a constant (e.g., `Foo = Class.new`), + // follow the `assigned_to` reference to get the constant's NameId. + if let Some(Definition::Dsl(dsl)) = self.local_graph.definitions().get(id) + && let Some(constant_id) = dsl.assigned_to() + && let Some(Definition::Constant(constant)) = self.local_graph.definitions().get(&constant_id) + { + return Some(*constant.name_id()); + } + None + } Nesting::Method(_) => None, }) } @@ -574,15 +702,7 @@ impl<'a> RubyIndexer<'a> { fn add_constant_definition(&mut self, node: &ruby_prism::Node, also_add_reference: bool) -> Option { let name_id = self.index_constant_reference(node, also_add_reference)?; - // Get the location for the constant name/path only (not including the value) - let location = match node { - ruby_prism::Node::ConstantWriteNode { .. } => node.as_constant_write_node().unwrap().name_loc(), - ruby_prism::Node::ConstantOrWriteNode { .. } => node.as_constant_or_write_node().unwrap().name_loc(), - ruby_prism::Node::ConstantPathNode { .. } => node.as_constant_path_node().unwrap().name_loc(), - _ => node.location(), - }; - - let offset = Offset::from_prism_location(&location); + let offset = Offset::from_prism_location(&constant_name_location(node)); let (comments, flags) = self.find_comments_for(offset.start()); let lexical_nesting_id = self.parent_lexical_scope_id(); @@ -742,59 +862,17 @@ impl<'a> RubyIndexer<'a> { } } - /// Handle dynamic class or module definitions, like `Module.new`, `Class.new`, `Data.define` and so on - fn handle_dynamic_class_or_module(&mut self, node: &ruby_prism::Node, value: &ruby_prism::Node) -> bool { - let Some(call_node) = value.as_call_node() else { - return false; - }; - - if call_node.name().as_slice() != b"new" { - return false; - } - - let Some(receiver) = call_node.receiver() else { - return false; - }; - - let receiver_name = receiver.location().as_slice(); - - // Handle `Module.new` - if receiver_name == b"Module" || receiver_name == b"::Module" { - self.handle_module_definition(&node.location(), Some(node), call_node.block(), Nesting::Owner); - return true; - } - - // Handle `Class.new` - if receiver_name == b"Class" || receiver_name == b"::Class" { - self.handle_class_definition( - &node.location(), - Some(node), - call_node.block(), - call_node.arguments().and_then(|args| args.arguments().iter().next()), - Nesting::Owner, - ); - return true; - } - - false - } - - /// Returns the definition ID of the current nesting (class, module, or singleton class), + /// Returns the definition ID of the current nesting (class, module, singleton class, or DSL), /// but skips methods in the definitions stack. fn current_nesting_definition_id(&self) -> Option { self.nesting_stack.iter().rev().find_map(|nesting| match nesting { - Nesting::LexicalScope(id) | Nesting::Owner(id) => Some(*id), + Nesting::LexicalScope(id) | Nesting::Dsl(id) => Some(*id), Nesting::Method(_) => None, }) } - /// Indexes the final constant target from a value node, unwrapping chained assignments. - /// - /// For `A = B = C`, when processing `A`, the value is `ConstantWriteNode(B)`. - /// This function recursively unwraps to find the final `ConstantReadNode(C)` and indexes it. - /// - /// Returns `Some(NameId)` if the final value is a constant (`ConstantReadNode` or `ConstantPathNode`), - /// or `None` if the chain ends in a non-constant value. + /// Gets the final constant target from a value, unwrapping chained assignments. + /// For `A = B = C`, returns C's `NameId`. Returns None if the chain ends in a non-constant. fn index_constant_alias_target(&mut self, value: &ruby_prism::Node) -> Option { match value { ruby_prism::Node::ConstantReadNode { .. } | ruby_prism::Node::ConstantPathNode { .. } => { @@ -836,15 +914,7 @@ impl<'a> RubyIndexer<'a> { ) -> Option { let name_id = self.index_constant_reference(name_node, also_add_reference)?; - // Get the location for just the constant name (not including the namespace or value). - let location = match name_node { - ruby_prism::Node::ConstantWriteNode { .. } => name_node.as_constant_write_node().unwrap().name_loc(), - ruby_prism::Node::ConstantOrWriteNode { .. } => name_node.as_constant_or_write_node().unwrap().name_loc(), - ruby_prism::Node::ConstantPathNode { .. } => name_node.as_constant_path_node().unwrap().name_loc(), - _ => name_node.location(), - }; - - let offset = Offset::from_prism_location(&location); + let offset = Offset::from_prism_location(&constant_name_location(name_node)); let (comments, flags) = self.find_comments_for(offset.start()); let lexical_nesting_id = self.parent_lexical_scope_id(); @@ -858,10 +928,113 @@ impl<'a> RubyIndexer<'a> { Some(definition_id) } - /// Adds a member to the current owner (class, module, or singleton class). - /// - /// Iterates through the definitions stack in reverse to find the first class/module/singleton - /// class, skipping methods, and adds the member to it. + /// Indexes a call node as a DSL definition if it matches a DSL target. + fn try_index_dsl_call( + &mut self, + call: &ruby_prism::CallNode, + assigned_to: Option, + ) -> Option { + let method_name = String::from_utf8_lossy(call.name().as_slice()); + if !self.is_dsl_target(&method_name) { + return None; + } + + let method_name_str_id = self.local_graph.intern_string(method_name.to_string()); + + let receiver_name_id = call.receiver().and_then(|receiver| match &receiver { + ruby_prism::Node::ConstantReadNode { .. } | ruby_prism::Node::ConstantPathNode { .. } => { + self.index_constant_reference(&receiver, true) + } + _ => None, + }); + + let offset = Offset::from_prism_location(&call.location()); + let lexical_nesting_id = self.parent_lexical_scope_id(); + let arguments = self.parse_dsl_arguments(call); + + let dsl_def = DslDefinition::new( + receiver_name_id, + method_name_str_id, + arguments, + self.uri_id, + offset, + lexical_nesting_id, + assigned_to, + ); + + let definition_id = dsl_def.id(); + self.local_graph.add_definition(Definition::Dsl(Box::new(dsl_def))); + + if let Some(block) = call.block() { + self.nesting_stack.push(Nesting::Dsl(definition_id)); + self.visit(&block); + self.nesting_stack.pop(); + } + + Some(definition_id) + } + + /// Creates a constant for DSL assignments, using `parent_nesting_id()` to include DSL parents. + fn create_constant_definition( + &mut self, + name_node: &ruby_prism::Node, + also_add_reference: bool, + ) -> Option { + let constant_name = self.index_constant_reference(name_node, also_add_reference)?; + + let offset = Offset::from_prism_location(&constant_name_location(name_node)); + let (comments, flags) = self.find_comments_for(offset.start()); + let lexical_nesting_id = self.parent_nesting_id(); + + let constant_def = + ConstantDefinition::new(constant_name, self.uri_id, offset, comments, flags, lexical_nesting_id); + let definition_id = constant_def.id(); + self.local_graph + .add_definition(Definition::Constant(Box::new(constant_def))); + + self.add_member_to_current_owner(definition_id); + + Some(definition_id) + } + + /// Indexes a constant assignment. Handles DSL calls, aliases, and chained assignments. + fn index_constant_assignment( + &mut self, + name_node: &ruby_prism::Node, + value: &ruby_prism::Node, + also_add_reference: bool, + ) { + // DSL call (e.g., `A = Class.new`) - create constant first for `class << self` resolution + if let ruby_prism::Node::CallNode { .. } = value { + let call = value.as_call_node().unwrap(); + let method_name = String::from_utf8_lossy(call.name().as_slice()); + if self.is_dsl_target(&method_name) { + let constant_def_id = self.create_constant_definition(name_node, also_add_reference); + self.try_index_dsl_call(&call, constant_def_id); + return; + } + } + + // Alias to existing constant (e.g., `A = Target` or `A = B = Target`) + // Note: index_constant_alias_target already creates inner definitions and references, + // so we don't need to visit(value) here. + if let Some(target_name_id) = self.index_constant_alias_target(value) { + self.add_constant_alias_definition(name_node, target_name_id, also_add_reference); + return; + } + + // Chained assignment (e.g., `A = B = Class.new`) - A aliases B + if let Some(inner_name_id) = self.get_chained_constant_name(value) { + self.visit(value); + self.add_constant_alias_definition(name_node, inner_name_id, also_add_reference); + return; + } + + self.add_constant_definition(name_node, also_add_reference); + self.visit(value); + } + + /// Adds a member to the current owner (class, module, singleton class, or DSL). fn add_member_to_current_owner(&mut self, member_id: DefinitionId) { let Some(owner_id) = self.current_nesting_definition_id() else { return; @@ -876,14 +1049,12 @@ impl<'a> RubyIndexer<'a> { Definition::Class(class) => class.add_member(member_id), Definition::SingletonClass(singleton_class) => singleton_class.add_member(member_id), Definition::Module(module) => module.add_member(member_id), - _ => unreachable!("find above only matches anonymous/class/module/singleton"), + Definition::Dsl(dsl) => dsl.add_member(member_id), + _ => unreachable!("find above only matches class/module/singleton/dsl"), } } - /// Adds a member to the current lexical scope - /// - /// Iterates through the definitions stack in reverse to find the first class/module/singleton class, skipping - /// methods, and adds the member to it. Ignores owner nestings such as Class.new + /// Adds a member to the current lexical scope (class, module, singleton class - excludes DSLs). fn add_member_to_current_lexical_scope(&mut self, member_id: DefinitionId) { let Some(owner_id) = self.parent_lexical_scope_id() else { return; @@ -968,6 +1139,7 @@ impl<'a> RubyIndexer<'a> { Definition::Class(class_def) => class_def.add_mixin(mixin), Definition::Module(module_def) => module_def.add_mixin(mixin), Definition::SingletonClass(singleton_class_def) => singleton_class_def.add_mixin(mixin), + Definition::Dsl(dsl_def) => dsl_def.add_mixin(mixin), _ => {} } } @@ -993,7 +1165,25 @@ impl<'a> RubyIndexer<'a> { fn parent_lexical_scope_id(&self) -> Option { self.nesting_stack.iter().rev().find_map(|nesting| match nesting { Nesting::LexicalScope(id) => Some(*id), - Nesting::Owner(_) | Nesting::Method(_) => None, + Nesting::Method(_) | Nesting::Dsl(_) => None, + }) + } + + /// Gets the parent for definitions that should be nested under DSLs (like singleton classes). + /// Unlike `parent_lexical_scope_id`, this includes DSLs that are assigned to a constant. + fn parent_for_singleton_class(&self) -> Option { + self.nesting_stack.iter().rev().find_map(|nesting| match nesting { + Nesting::LexicalScope(id) => Some(*id), + Nesting::Dsl(id) => { + // Include DSLs assigned to a constant, as they create a namespace for singleton classes. + if let Some(Definition::Dsl(dsl)) = self.local_graph.definitions().get(id) + && dsl.assigned_to().is_some() + { + return Some(*id); + } + None + } + Nesting::Method(_) => None, }) } @@ -1021,7 +1211,7 @@ impl<'a> RubyIndexer<'a> { // Implicit or explicit self receiver match self.nesting_stack.last() { - Some(Nesting::LexicalScope(id) | Nesting::Owner(id)) => { + Some(Nesting::LexicalScope(id) | Nesting::Dsl(id)) => { let definition = self .local_graph .definitions() @@ -1032,7 +1222,7 @@ impl<'a> RubyIndexer<'a> { Definition::Class(class_def) => Some(*class_def.name_id()), Definition::Module(module_def) => Some(*module_def.name_id()), Definition::SingletonClass(singleton_class_def) => Some(*singleton_class_def.name_id()), - Definition::Method(_) => None, + Definition::Method(_) | Definition::Dsl(_) => None, _ => panic!("current nesting is not a class/module/singleton class: {definition:?}"), } } @@ -1044,7 +1234,18 @@ impl<'a> RubyIndexer<'a> { if let Some(method_def_receiver) = definition.receiver() { is_singleton_name = true; - Some(*method_def_receiver) + match method_def_receiver { + Receiver::ConstantReceiver(name_id) => Some(name_id), + Receiver::SelfReceiver(def_id) => { + // Get the name_id from the definition + self.local_graph.definitions().get(&def_id).and_then(|def| match def { + Definition::Class(c) => Some(*c.name_id()), + Definition::Module(m) => Some(*m.name_id()), + Definition::SingletonClass(s) => Some(*s.name_id()), + _ => None, + }) + } + } } else { self.current_owner_name_id() } @@ -1192,9 +1393,10 @@ impl Visit<'_> for RubyIndexer<'_> { // Determine the attached_target for the singleton class and the name_offset let (attached_target, name_offset) = if expression.as_self_node().is_some() { // `class << self` - resolve self to current class/module's NameId + // For DSL blocks (e.g., `Foo = Class.new do...end`), use the constant's NameId // name_offset points to "self" ( - self.current_lexical_scope_name_id(), + self.current_owner_name_id(), Offset::from_prism_location(&expression.location()), ) } else if matches!( @@ -1230,7 +1432,7 @@ impl Visit<'_> for RubyIndexer<'_> { let offset = Offset::from_prism_location(&node.location()); let (comments, flags) = self.find_comments_for(offset.start()); - let lexical_nesting_id = self.parent_lexical_scope_id(); + let lexical_nesting_id = self.parent_for_singleton_class(); let singleton_class_name = { let name = self @@ -1286,26 +1488,11 @@ impl Visit<'_> for RubyIndexer<'_> { } fn visit_constant_or_write_node(&mut self, node: &ruby_prism::ConstantOrWriteNode) { - if let Some(target_name_id) = self.index_constant_alias_target(&node.value()) { - self.add_constant_alias_definition(&node.as_node(), target_name_id, true); - } else { - self.add_constant_definition(&node.as_node(), true); - self.visit(&node.value()); - } + self.index_constant_assignment(&node.as_node(), &node.value(), true); } fn visit_constant_write_node(&mut self, node: &ruby_prism::ConstantWriteNode) { - let value = node.value(); - if self.handle_dynamic_class_or_module(&node.as_node(), &value) { - return; - } - - if let Some(target_name_id) = self.index_constant_alias_target(&value) { - self.add_constant_alias_definition(&node.as_node(), target_name_id, false); - } else { - self.add_constant_definition(&node.as_node(), false); - self.visit(&value); - } + self.index_constant_assignment(&node.as_node(), &node.value(), false); } fn visit_constant_path_and_write_node(&mut self, node: &ruby_prism::ConstantPathAndWriteNode) { @@ -1319,26 +1506,11 @@ impl Visit<'_> for RubyIndexer<'_> { } fn visit_constant_path_or_write_node(&mut self, node: &ruby_prism::ConstantPathOrWriteNode) { - if let Some(target_name_id) = self.index_constant_alias_target(&node.value()) { - self.add_constant_alias_definition(&node.target().as_node(), target_name_id, true); - } else { - self.add_constant_definition(&node.target().as_node(), true); - self.visit(&node.value()); - } + self.index_constant_assignment(&node.target().as_node(), &node.value(), true); } fn visit_constant_path_write_node(&mut self, node: &ruby_prism::ConstantPathWriteNode) { - let value = node.value(); - if self.handle_dynamic_class_or_module(&node.as_node(), &value) { - return; - } - - if let Some(target_name_id) = self.index_constant_alias_target(&value) { - self.add_constant_alias_definition(&node.target().as_node(), target_name_id, false); - } else { - self.add_constant_definition(&node.target().as_node(), false); - self.visit(&value); - } + self.index_constant_assignment(&node.target().as_node(), &node.value(), false); } fn visit_constant_read_node(&mut self, node: &ruby_prism::ConstantReadNode<'_>) { @@ -1414,14 +1586,14 @@ impl Visit<'_> for RubyIndexer<'_> { let (comments, flags) = self.find_comments_for(offset_for_comments.start()); - let receiver = if let Some(recv_node) = node.receiver() { + let receiver: Option = if let Some(recv_node) = node.receiver() { match recv_node { - // def self.foo - receiver is the current owner's NameId (includes Class.new/Module.new) - ruby_prism::Node::SelfNode { .. } => self.current_owner_name_id(), - // def Foo.bar or def Foo::Bar.baz - receiver is the constant's NameId - ruby_prism::Node::ConstantPathNode { .. } | ruby_prism::Node::ConstantReadNode { .. } => { - self.index_constant_reference(&recv_node, true) - } + // def self.foo - receiver is the enclosing class/module/DSL definition + ruby_prism::Node::SelfNode { .. } => self.current_nesting_definition_id().map(Receiver::SelfReceiver), + // def Foo.bar or def Foo::Bar.baz - receiver is an explicit constant + ruby_prism::Node::ConstantPathNode { .. } | ruby_prism::Node::ConstantReadNode { .. } => self + .index_constant_reference(&recv_node, true) + .map(Receiver::ConstantReceiver), // Dynamic receiver (def foo.bar) - visit and then skip // We still want to visit because it could be a variable reference _ => { @@ -1442,6 +1614,7 @@ impl Visit<'_> for RubyIndexer<'_> { let definition_id = if receiver.is_none() && visibility == Visibility::ModuleFunction { // module_function creates two method definitions: // 1. Public singleton method (class/module method) + let self_receiver = self.current_nesting_definition_id().map(Receiver::SelfReceiver); let method = Definition::Method(Box::new(MethodDefinition::new( str_id, self.uri_id, @@ -1451,7 +1624,7 @@ impl Visit<'_> for RubyIndexer<'_> { parent_nesting_id, parameters.clone(), Visibility::Public, - self.current_owner_name_id(), + self_receiver, ))); let definition_id = self.local_graph.add_definition(method); @@ -1508,6 +1681,12 @@ impl Visit<'_> for RubyIndexer<'_> { Writer, } + // Check if this is a standalone DSL call (e.g., Class.new do...end) + // DSL calls assigned to constants are handled in visit_constant_write_node + if self.try_index_dsl_call(node, None).is_some() { + return; + } + let mut index_attr = |kind: AttrKind, call: &ruby_prism::CallNode| { let call_offset = Offset::from_prism_location(&call.location()); @@ -1702,31 +1881,6 @@ impl Visit<'_> for RubyIndexer<'_> { *last_visibility = VisibilityModifier::new(visibility, false, offset); } } - "new" => { - if let Some(receiver) = node.receiver() { - { - let receiver_name = receiver.location().as_slice(); - - if receiver_name == b"Class" || receiver_name == b"::Class" { - self.handle_class_definition( - &node.location(), - None, - node.block(), - node.arguments().and_then(|args| args.arguments().iter().next()), - Nesting::Owner, - ); - return; - } - - if receiver_name == b"Module" || receiver_name == b"::Module" { - self.handle_module_definition(&node.location(), None, node.block(), Nesting::Owner); - return; - } - } - } - - self.visit_call_node_parts(node); - } _ => { // For method calls that we don't explicitly handle each part, we continue visiting their parts as we // may discover something inside @@ -1996,8 +2150,9 @@ impl Visit<'_> for RubyIndexer<'_> { mod tests { use crate::{ model::{ - definitions::{Definition, Mixin, Parameter}, - ids::{StringId, UriId}, + definitions::{Definition, Mixin, Parameter, Receiver}, + dsl::DslArgument, + ids::StringId, visibility::Visibility, }, test_utils::LocalGraphTest, @@ -2089,6 +2244,40 @@ mod tests { }}; } + /// Asserts that a method has the expected receiver. + /// + /// Usage: + /// - `assert_method_has_receiver!(ctx, method, "Foo")` for constant receiver + /// - `assert_method_has_receiver!(ctx, method, "Bar")` for self receiver + macro_rules! assert_method_has_receiver { + ($context:expr, $method:expr, $expected_receiver:expr) => {{ + use crate::model::definitions::Receiver; + if let Some(receiver) = $method.receiver() { + let actual_name = match receiver { + Receiver::SelfReceiver(def_id) => { + // Get the definition's name + let def = $context.graph().definitions().get(&def_id).unwrap(); + let name_id = match def { + crate::model::definitions::Definition::Class(c) => *c.name_id(), + crate::model::definitions::Definition::Module(m) => *m.name_id(), + crate::model::definitions::Definition::SingletonClass(s) => *s.name_id(), + _ => panic!("unexpected definition type for SelfReceiver"), + }; + let name = $context.graph().names().get(&name_id).unwrap(); + $context.graph().strings().get(name.str()).unwrap().as_str().to_string() + } + Receiver::ConstantReceiver(name_id) => { + let name = $context.graph().names().get(&name_id).unwrap(); + $context.graph().strings().get(name.str()).unwrap().as_str().to_string() + } + }; + assert_eq!($expected_receiver, actual_name); + } else { + panic!("expected method to have receiver, got None"); + } + }}; + } + /// Asserts that a definition's mixins matches the expected mixins. /// /// Usage: @@ -2202,32 +2391,22 @@ mod tests { }}; } - // Method assertions - - /// Asserts that a method has the expected receiver. - /// - /// Usage: - /// - `assert_method_has_receiver!(ctx, method, "Foo")` - /// - `assert_method_has_receiver!(ctx, method, "")` - macro_rules! assert_method_has_receiver { - ($context:expr, $method:expr, $expected_receiver:expr) => {{ - if let Some(receiver_name_id) = $method.receiver() { - let name = $context.graph().names().get(receiver_name_id).unwrap(); - let actual_name = $context.graph().strings().get(name.str()).unwrap().as_str(); - assert_eq!( - $expected_receiver, actual_name, - "method receiver mismatch: expected `{}`, got `{}`", - $expected_receiver, actual_name - ); - } else { - panic!( - "Method receiver mismatch: expected `{}`, got `None`", - $expected_receiver - ); - } + macro_rules! assert_block_offset_eq { + ($context:expr, $expected_location:expr, $dsl_args:expr) => {{ + let (_, expected_offset) = $context.parse_location(&format!("{}:{}", $context.uri(), $expected_location)); + let actual_offset = $dsl_args.block_offset().expect("expected block offset to be set"); + assert_eq!( + &expected_offset, + actual_offset, + "block_offset mismatch: expected {}, got {}", + expected_offset.to_display_range($context.graph().document()), + actual_offset.to_display_range($context.graph().document()) + ); }}; } + // Method assertions + /// Asserts that a parameter matches the expected kind. /// /// Usage: @@ -5128,39 +5307,25 @@ mod tests { assert_no_diagnostics!(&context); assert_definition_at!(&context, "1:1-10:4", Module, |foo| { - assert_definition_at!(&context, "2:3-9:6", Module, |bar| { - assert_definition_at!(&context, "5:5-7:8", Method, |qux| { - assert_definition_at!(&context, "6:7-6:11", InstanceVariable, |var| { - assert_definition_at!(&context, "8:18-8:23", AttrReader, |hello| { - assert_def_name_eq!(&context, bar, "Bar"); - assert_eq!(foo.id(), bar.lexical_nesting_id().unwrap()); - assert_eq!(foo.members()[0], bar.id()); - - assert_eq!(bar.members()[0], qux.id()); - assert_eq!(bar.members()[1], var.id()); - assert_eq!(bar.members()[2], hello.id()); - - // We expect the `Baz` constant name to NOT be associated with `Bar` because `Module.new` does not - // produce a new lexical scope - let include = bar.mixins().first().unwrap(); - let name = context - .graph() - .names() - .get( - context - .graph() - .constant_references() - .get(include.constant_reference_id()) - .unwrap() - .name_id(), - ) - .unwrap(); - - assert_eq!(StringId::from("Baz"), *name.str()); - assert!(name.parent_scope().is_none()); - - let nesting_name = context.graph().names().get(&name.nesting().unwrap()).unwrap(); - assert_eq!(StringId::from("Foo"), *nesting_name.str()); + // The constant assignment is at 2:3-2:6 (Bar) + assert_definition_at!(&context, "2:3-2:6", Constant, |const_def| { + // The DSL call is at 2:9-9:6 (Module.new do ... end) + assert_definition_at!(&context, "2:9-9:6", Dsl, |dsl| { + // Check Dsl links to Constant via assigned_to + assert_eq!(Some(const_def.id()), dsl.assigned_to()); + assert_eq!(foo.id(), dsl.lexical_nesting_id().unwrap()); + + // Check the include mixin is on the Dsl + assert_def_mixins_eq!(&context, dsl, Include, vec!["Baz"]); + + assert_definition_at!(&context, "5:5-7:8", Method, |qux| { + assert_definition_at!(&context, "6:7-6:11", InstanceVariable, |var| { + assert_definition_at!(&context, "8:18-8:23", AttrReader, |hello| { + // Members are on the Dsl definition + assert_eq!(dsl.members()[0], qux.id()); + assert_eq!(dsl.members()[1], var.id()); + assert_eq!(dsl.members()[2], hello.id()); + }); }); }); }); @@ -5187,39 +5352,25 @@ mod tests { assert_no_diagnostics!(&context); assert_definition_at!(&context, "1:1-10:4", Module, |foo| { - assert_definition_at!(&context, "2:3-9:6", Module, |bar| { - assert_definition_at!(&context, "5:5-7:8", Method, |qux| { - assert_definition_at!(&context, "6:7-6:11", InstanceVariable, |var| { - assert_definition_at!(&context, "8:18-8:23", AttrReader, |hello| { - assert_def_name_eq!(&context, bar, "Zip::Bar"); - assert_eq!(foo.id(), bar.lexical_nesting_id().unwrap()); - assert_eq!(foo.members()[0], bar.id()); - - assert_eq!(bar.members()[0], qux.id()); - assert_eq!(bar.members()[1], var.id()); - assert_eq!(bar.members()[2], hello.id()); - - // We expect the `Baz` constant name to NOT be associated with `Bar` because `Module.new` does not - // produce a new lexical scope - let include = bar.mixins().first().unwrap(); - let name = context - .graph() - .names() - .get( - context - .graph() - .constant_references() - .get(include.constant_reference_id()) - .unwrap() - .name_id(), - ) - .unwrap(); - - assert_eq!(StringId::from("Baz"), *name.str()); - assert!(name.parent_scope().is_none()); - - let nesting_name = context.graph().names().get(&name.nesting().unwrap()).unwrap(); - assert_eq!(StringId::from("Foo"), *nesting_name.str()); + // The constant assignment is at 2:8-2:11 (Bar in Zip::Bar) + assert_definition_at!(&context, "2:8-2:11", Constant, |const_def| { + // The DSL call is at 2:14-9:6 (Module.new do ... end) + assert_definition_at!(&context, "2:14-9:6", Dsl, |dsl| { + // Check Dsl links to Constant via assigned_to + assert_eq!(Some(const_def.id()), dsl.assigned_to()); + assert_eq!(foo.id(), dsl.lexical_nesting_id().unwrap()); + + // Check the include mixin is on the Dsl + assert_def_mixins_eq!(&context, dsl, Include, vec!["Baz"]); + + assert_definition_at!(&context, "5:5-7:8", Method, |qux| { + assert_definition_at!(&context, "6:7-6:11", InstanceVariable, |var| { + assert_definition_at!(&context, "8:18-8:23", AttrReader, |hello| { + // Members are on the Dsl definition + assert_eq!(dsl.members()[0], qux.id()); + assert_eq!(dsl.members()[1], var.id()); + assert_eq!(dsl.members()[2], hello.id()); + }); }); }); }); @@ -5246,41 +5397,22 @@ mod tests { assert_no_diagnostics!(&context); assert_definition_at!(&context, "1:1-10:4", Module, |foo| { - assert_definition_at!(&context, "2:3-9:6", Class, |bar| { - assert_definition_at!(&context, "5:5-7:8", Method, |qux| { - assert_definition_at!(&context, "6:7-6:11", InstanceVariable, |var| { - assert_definition_at!(&context, "8:18-8:23", AttrReader, |hello| { - assert_def_name_eq!(&context, bar, "Bar"); - assert_eq!(foo.id(), bar.lexical_nesting_id().unwrap()); - assert_eq!(foo.members()[0], bar.id()); - - assert_eq!(bar.members()[0], qux.id()); - assert_eq!(bar.members()[1], var.id()); - assert_eq!(bar.members()[2], hello.id()); - - assert_def_superclass_ref_eq!(&context, bar, "Parent"); - - // We expect the `Baz` constant name to NOT be associated with `Bar` because `Module.new` does not - // produce a new lexical scope - let include = bar.mixins().first().unwrap(); - let name = context - .graph() - .names() - .get( - context - .graph() - .constant_references() - .get(include.constant_reference_id()) - .unwrap() - .name_id(), - ) - .unwrap(); - - assert_eq!(StringId::from("Baz"), *name.str()); - assert!(name.parent_scope().is_none()); - - let nesting_name = context.graph().names().get(&name.nesting().unwrap()).unwrap(); - assert_eq!(StringId::from("Foo"), *nesting_name.str()); + assert_definition_at!(&context, "2:3-2:6", Constant, |const_def| { + assert_definition_at!(&context, "2:9-9:6", Dsl, |dsl| { + assert_eq!(Some(const_def.id()), dsl.assigned_to()); + assert_eq!(foo.id(), dsl.lexical_nesting_id().unwrap()); + // Class.new(Parent) - Parent is indexed as a constant reference + assert!(dsl.arguments().first_positional_reference().is_some()); + assert_block_offset_eq!(&context, "2:27-9:6", dsl.arguments()); + assert_def_mixins_eq!(&context, dsl, Include, vec!["Baz"]); + + assert_definition_at!(&context, "5:5-7:8", Method, |qux| { + assert_definition_at!(&context, "6:7-6:11", InstanceVariable, |var| { + assert_definition_at!(&context, "8:18-8:23", AttrReader, |hello| { + assert_eq!(dsl.members()[0], qux.id()); + assert_eq!(dsl.members()[1], var.id()); + assert_eq!(dsl.members()[2], hello.id()); + }); }); }); }); @@ -5307,39 +5439,20 @@ mod tests { assert_no_diagnostics!(&context); assert_definition_at!(&context, "1:1-10:4", Module, |foo| { - assert_definition_at!(&context, "2:3-9:6", Class, |bar| { - assert_definition_at!(&context, "5:5-7:8", Method, |qux| { - assert_definition_at!(&context, "6:7-6:11", InstanceVariable, |var| { - assert_definition_at!(&context, "8:18-8:23", AttrReader, |hello| { - assert_def_name_eq!(&context, bar, "Bar"); - assert_eq!(foo.id(), bar.lexical_nesting_id().unwrap()); - assert_eq!(foo.members()[0], bar.id()); - - assert_eq!(bar.members()[0], qux.id()); - assert_eq!(bar.members()[1], var.id()); - assert_eq!(bar.members()[2], hello.id()); - - // We expect the `Baz` constant name to NOT be associated with `Bar` because `Module.new` does not - // produce a new lexical scope - let include = bar.mixins().first().unwrap(); - let name = context - .graph() - .names() - .get( - context - .graph() - .constant_references() - .get(include.constant_reference_id()) - .unwrap() - .name_id(), - ) - .unwrap(); - - assert_eq!(StringId::from("Baz"), *name.str()); - assert!(name.parent_scope().is_none()); - - let nesting_name = context.graph().names().get(&name.nesting().unwrap()).unwrap(); - assert_eq!(StringId::from("Foo"), *nesting_name.str()); + assert_definition_at!(&context, "2:3-2:6", Constant, |const_def| { + assert_definition_at!(&context, "2:9-9:6", Dsl, |dsl| { + assert_eq!(Some(const_def.id()), dsl.assigned_to()); + assert_eq!(foo.id(), dsl.lexical_nesting_id().unwrap()); + assert_block_offset_eq!(&context, "2:19-9:6", dsl.arguments()); + assert_def_mixins_eq!(&context, dsl, Include, vec!["Baz"]); + + assert_definition_at!(&context, "5:5-7:8", Method, |qux| { + assert_definition_at!(&context, "6:7-6:11", InstanceVariable, |var| { + assert_definition_at!(&context, "8:18-8:23", AttrReader, |hello| { + assert_eq!(dsl.members()[0], qux.id()); + assert_eq!(dsl.members()[1], var.id()); + assert_eq!(dsl.members()[2], hello.id()); + }); }); }); }); @@ -5366,41 +5479,22 @@ mod tests { assert_no_diagnostics!(&context); assert_definition_at!(&context, "1:1-10:4", Module, |foo| { - assert_definition_at!(&context, "2:3-9:6", Class, |bar| { - assert_definition_at!(&context, "5:5-7:8", Method, |qux| { - assert_definition_at!(&context, "6:7-6:11", InstanceVariable, |var| { - assert_definition_at!(&context, "8:18-8:23", AttrReader, |hello| { - assert_def_name_eq!(&context, bar, "Zip::Bar"); - assert_eq!(foo.id(), bar.lexical_nesting_id().unwrap()); - assert_eq!(foo.members()[0], bar.id()); - - assert_eq!(bar.members()[0], qux.id()); - assert_eq!(bar.members()[1], var.id()); - assert_eq!(bar.members()[2], hello.id()); - - assert_def_superclass_ref_eq!(&context, bar, "Parent"); - - // We expect the `Baz` constant name to NOT be associated with `Bar` because `Module.new` does not - // produce a new lexical scope - let include = bar.mixins().first().unwrap(); - let name = context - .graph() - .names() - .get( - context - .graph() - .constant_references() - .get(include.constant_reference_id()) - .unwrap() - .name_id(), - ) - .unwrap(); - - assert_eq!(StringId::from("Baz"), *name.str()); - assert!(name.parent_scope().is_none()); - - let nesting_name = context.graph().names().get(&name.nesting().unwrap()).unwrap(); - assert_eq!(StringId::from("Foo"), *nesting_name.str()); + assert_definition_at!(&context, "2:8-2:11", Constant, |const_def| { + assert_definition_at!(&context, "2:14-9:6", Dsl, |dsl| { + assert_eq!(Some(const_def.id()), dsl.assigned_to()); + assert_eq!(foo.id(), dsl.lexical_nesting_id().unwrap()); + // Class.new(Parent) - Parent is indexed as a constant reference + assert!(dsl.arguments().first_positional_reference().is_some()); + assert_block_offset_eq!(&context, "2:32-9:6", dsl.arguments()); + assert_def_mixins_eq!(&context, dsl, Include, vec!["Baz"]); + + assert_definition_at!(&context, "5:5-7:8", Method, |qux| { + assert_definition_at!(&context, "6:7-6:11", InstanceVariable, |var| { + assert_definition_at!(&context, "8:18-8:23", AttrReader, |hello| { + assert_eq!(dsl.members()[0], qux.id()); + assert_eq!(dsl.members()[1], var.id()); + assert_eq!(dsl.members()[2], hello.id()); + }); }); }); }); @@ -5424,14 +5518,21 @@ mod tests { assert_no_diagnostics!(&context); assert_definition_at!(&context, "1:1-7:4", Module, |foo| { - assert_definition_at!(&context, "2:3-3:6", Class, |bar| { - assert_definition_at!(&context, "5:3-6:6", Module, |baz| { - assert_def_name_eq!(&context, bar, "Bar"); - assert_def_name_eq!(&context, baz, "Baz"); - assert_eq!(foo.id(), bar.lexical_nesting_id().unwrap()); - assert_eq!(foo.id(), baz.lexical_nesting_id().unwrap()); - assert_eq!(foo.members()[0], bar.id()); - assert_eq!(foo.members()[1], baz.id()); + // Bar = ::Class.new do end + assert_definition_at!(&context, "2:9-3:6", Dsl, |bar_dsl| { + assert_definition_at!(&context, "2:3-2:6", Constant, |bar_const| { + // Baz = ::Module.new do end + assert_definition_at!(&context, "5:9-6:6", Dsl, |baz_dsl| { + assert_definition_at!(&context, "5:3-5:6", Constant, |baz_const| { + // Check Dsl links to Constant via assigned_to + assert_eq!(Some(bar_const.id()), bar_dsl.assigned_to()); + assert_eq!(Some(baz_const.id()), baz_dsl.assigned_to()); + + // Check lexical nesting - Dsl definitions are nested in Foo + assert_eq!(foo.id(), bar_dsl.lexical_nesting_id().unwrap()); + assert_eq!(foo.id(), baz_dsl.lexical_nesting_id().unwrap()); + }); + }); }); }); }); @@ -5455,19 +5556,21 @@ mod tests { assert_no_diagnostics!(&context); assert_definition_at!(&context, "1:1-9:4", Module, |foo| { - assert_definition_at!(&context, "2:3-4:6", Class, |anonymous| { - assert_eq!(foo.id(), anonymous.lexical_nesting_id().unwrap()); + assert_definition_at!(&context, "2:3-4:6", Dsl, |dsl| { + assert_eq!(foo.id(), dsl.lexical_nesting_id().unwrap()); + assert_block_offset_eq!(&context, "2:13-4:6", dsl.arguments()); assert_definition_at!(&context, "3:5-3:17", Method, |bar| { - assert_eq!(anonymous.id(), bar.lexical_nesting_id().unwrap()); + assert_eq!(dsl.id(), bar.lexical_nesting_id().unwrap()); }); }); - assert_definition_at!(&context, "6:3-8:6", Module, |anonymous| { - assert_eq!(foo.id(), anonymous.lexical_nesting_id().unwrap()); + assert_definition_at!(&context, "6:3-8:6", Dsl, |dsl| { + assert_eq!(foo.id(), dsl.lexical_nesting_id().unwrap()); + assert_block_offset_eq!(&context, "6:14-8:6", dsl.arguments()); assert_definition_at!(&context, "7:5-7:17", Method, |baz| { - assert_eq!(anonymous.id(), baz.lexical_nesting_id().unwrap()); + assert_eq!(dsl.id(), baz.lexical_nesting_id().unwrap()); }); }); }); @@ -5488,11 +5591,13 @@ mod tests { assert_no_diagnostics!(&context); assert_definition_at!(&context, "1:1-6:4", Module, |foo| { - assert_definition_at!(&context, "2:3-5:6", Class, |anonymous_class| { - assert_eq!(foo.id(), anonymous_class.lexical_nesting_id().unwrap()); + assert_definition_at!(&context, "2:3-5:6", Dsl, |outer_dsl| { + assert_eq!(foo.id(), outer_dsl.lexical_nesting_id().unwrap()); + assert_block_offset_eq!(&context, "2:13-5:6", outer_dsl.arguments()); - assert_definition_at!(&context, "3:5-4:8", Module, |anonymous_module| { - assert_eq!(foo.id(), anonymous_module.lexical_nesting_id().unwrap()); + assert_definition_at!(&context, "3:5-4:8", Dsl, |inner_dsl| { + assert_eq!(foo.id(), inner_dsl.lexical_nesting_id().unwrap()); + assert_block_offset_eq!(&context, "3:16-4:8", inner_dsl.arguments()); }); }); }); @@ -5513,8 +5618,9 @@ mod tests { assert_no_diagnostics!(&context); assert_definition_at!(&context, "1:1-6:4", Module, |foo| { - assert_definition_at!(&context, "2:3-5:6", Class, |anonymous_class| { - assert_eq!(foo.id(), anonymous_class.lexical_nesting_id().unwrap()); + assert_definition_at!(&context, "2:3-5:6", Dsl, |dsl| { + assert_eq!(foo.id(), dsl.lexical_nesting_id().unwrap()); + assert_block_offset_eq!(&context, "2:13-5:6", dsl.arguments()); assert_definition_at!(&context, "3:5-4:8", Module, |bar| { assert_eq!(foo.id(), bar.lexical_nesting_id().unwrap()); @@ -5537,10 +5643,10 @@ mod tests { assert_no_diagnostics!(&context); assert_definition_at!(&context, "1:1-5:4", Module, |foo| { - assert_definition_at!(&context, "2:3-4:6", Class, |anonymous_class| { - assert_eq!(foo.id(), anonymous_class.lexical_nesting_id().unwrap()); - - assert_def_mixins_eq!(&context, anonymous_class, Include, ["Bar"]); + assert_definition_at!(&context, "2:3-4:6", Dsl, |dsl| { + assert_eq!(foo.id(), dsl.lexical_nesting_id().unwrap()); + assert_block_offset_eq!(&context, "2:13-4:6", dsl.arguments()); + assert_def_mixins_eq!(&context, dsl, Include, vec!["Bar"]); }); }); } @@ -5559,13 +5665,20 @@ mod tests { }); assert_no_diagnostics!(&context); - assert_definition_at!(&context, "3:5-4:8", Method, |bar| { - let receiver = bar.receiver().unwrap(); - let name_ref = context.graph().names().get(&receiver).unwrap(); - assert_eq!(StringId::from("A"), *name_ref.str()); + assert_definition_at!(&context, "2:7-5:6", Dsl, |dsl| { + assert_definition_at!(&context, "3:5-4:8", Method, |bar| { + // def self.bar - receiver is the DSL definition + let receiver = bar.receiver().unwrap(); + match receiver { + Receiver::SelfReceiver(def_id) => { + assert_eq!(dsl.id(), def_id); + } + Receiver::ConstantReceiver(_) => panic!("expected SelfReceiver"), + } - let nesting_name = context.graph().names().get(&name_ref.nesting().unwrap()).unwrap(); - assert_eq!(StringId::from("Foo"), *nesting_name.str()); + // lexical_nesting_id should point to the DSL definition + assert_eq!(Some(dsl.id()), *bar.lexical_nesting_id()); + }); }); } @@ -5585,12 +5698,118 @@ mod tests { assert_no_diagnostics!(&context); assert_definition_at!(&context, "1:1-7:4", Module, |foo| { - assert_definition_at!(&context, "4:7-4:12", ClassVariable, |var| { - assert_eq!(foo.id(), var.lexical_nesting_id().unwrap()); + assert_definition_at!(&context, "2:7-6:6", Dsl, |dsl| { + assert_eq!(foo.id(), dsl.lexical_nesting_id().unwrap()); + assert_definition_at!(&context, "4:7-4:12", ClassVariable, |var| { + assert_eq!(foo.id(), var.lexical_nesting_id().unwrap()); + }); + }); + }); + } + + #[test] + fn index_class_new_with_path_assignment() { + let context = index_source({ + " + module Foo; end + Foo::Bar = Class.new + " + }); + assert_no_diagnostics!(&context); + + assert_definition_at!(&context, "2:6-2:9", Constant, |bar| { + assert_definition_at!(&context, "2:12-2:21", Dsl, |dsl| { + assert_eq!(bar.id(), dsl.assigned_to().unwrap()); }); }); } + #[test] + fn index_class_new_with_or_assignment() { + let context = index_source({ + " + Foo ||= Class.new + " + }); + assert_no_diagnostics!(&context); + + assert_definition_at!(&context, "1:1-1:4", Constant, |foo| { + assert_definition_at!(&context, "1:9-1:18", Dsl, |dsl| { + assert_eq!(foo.id(), dsl.assigned_to().unwrap()); + assert_constant_references_eq!(&context, ["Foo", "Class"]); + }); + }); + } + + #[test] + fn index_class_new_with_path_or_assignment() { + let context = index_source({ + " + module Foo; end + Foo::Bar ||= Class.new + " + }); + assert_no_diagnostics!(&context); + + assert_definition_at!(&context, "2:6-2:9", Constant, |bar| { + assert_definition_at!(&context, "2:14-2:23", Dsl, |dsl| { + assert_eq!(bar.id(), dsl.assigned_to().unwrap()); + assert_constant_references_eq!(&context, ["Foo", "Bar", "Class"]); + }); + }); + } + + #[test] + fn index_class_new_with_chained_assignment() { + // For `A = B = Class.new`, Ruby evaluates right-to-left: + // - Class.new creates a class + // - Assigned to B first (B is the primary constant) + // - Then assigned to A (A is an alias to B) + let context = index_source({ + " + A = B = Class.new + " + }); + assert_no_diagnostics!(&context); + + assert_definition_at!(&context, "1:1-1:2", ConstantAlias, |a| { + assert_definition_at!(&context, "1:5-1:6", Constant, |b| { + assert_definition_at!(&context, "1:9-1:18", Dsl, |dsl| { + assert_eq!(b.id(), dsl.assigned_to().unwrap()); + assert_eq!(a.target_name_id(), b.name_id()); + }); + }); + }); + } + + #[test] + fn index_alias_method_in_anonymous_class() { + let context = index_source({ + " + Class.new do + alias_method :bar, :baz + end + " + }); + assert_no_diagnostics!(&context); + assert_method_references_eq!(&context, ["baz()"]); + } + + #[test] + fn index_method_reference_inside_anonymous_class() { + let context = index_source({ + " + Class.new do + def bar + baz + end + end + " + }); + assert_no_diagnostics!(&context); + assert_method_references_eq!(&context, ["baz"]); + } + #[test] fn index_singleton_method_in_anonymous_namespace() { let context = index_source({ @@ -5605,13 +5824,69 @@ mod tests { }); assert_no_diagnostics!(&context); - assert_definition_at!(&context, "3:5-4:8", Method, |bar| { - let receiver = bar.receiver().unwrap(); - let name_ref = context.graph().names().get(&receiver).unwrap(); - let uri_id = UriId::from("file:///foo.rb"); - assert_eq!(StringId::from(&format!("{uri_id}:13")), *name_ref.str()); - assert!(name_ref.nesting().is_none()); - assert!(name_ref.parent_scope().is_none()); + assert_definition_at!(&context, "2:3-5:6", Dsl, |dsl| { + assert_definition_at!(&context, "3:5-4:8", Method, |bar| { + // def self.bar - receiver is the DSL definition + let receiver = bar.receiver().unwrap(); + match receiver { + Receiver::SelfReceiver(def_id) => { + assert_eq!(dsl.id(), def_id); + } + Receiver::ConstantReceiver(_) => panic!("expected SelfReceiver"), + } + + // lexical_nesting_id should point to the DSL definition + assert_eq!(Some(dsl.id()), *bar.lexical_nesting_id()); + }); + }); + } + + #[test] + fn index_singleton_class_inside_class_new() { + let context = index_source({ + " + Foo = Class.new do + class << self + def singleton_method; end + end + end + " + }); + + assert_definition_at!(&context, "1:7-5:4", Dsl, |dsl| { + assert_definition_at!(&context, "2:3-4:6", SingletonClass, |singleton| { + // Singleton class is nested in the DSL + assert_eq!(singleton.lexical_nesting_id(), &Some(dsl.id())); + + // Method inside singleton + assert_definition_at!(&context, "3:5-3:30", Method, |method| { + assert_eq!(method.lexical_nesting_id(), &Some(singleton.id())); + }); + }); + }); + } + + #[test] + fn index_nested_class_new_inside_singleton_class() { + let context = index_source({ + " + Foo = Class.new do + class << self + Bar = Class.new do + def bar_method; end + end + end + end + " + }); + + assert_definition_at!(&context, "2:3-6:6", SingletonClass, |singleton| { + assert_definition_at!(&context, "3:5-3:8", Constant, |bar_const| { + assert_definition_at!(&context, "3:11-5:8", Dsl, |inner_dsl| { + assert_eq!(Some(bar_const.id()), inner_dsl.assigned_to()); + assert_eq!(inner_dsl.lexical_nesting_id(), &Some(singleton.id())); + }); + }); }); } @@ -6013,4 +6288,93 @@ mod tests { assert_def_comments_eq!(&context, def, ["# Comment"]); }); } + + #[test] + fn index_dsl_captures_all_argument_types() { + let context = index_source({ + r#" + Class.new(Parent, "string_arg", *splat_args, key: value, **double_splat) do + end + "# + }); + assert_no_diagnostics!(&context); + + let dsl_defs: Vec<_> = context + .graph() + .definitions() + .iter() + .filter_map(|(_, def)| match def { + Definition::Dsl(d) => Some(d), + _ => None, + }) + .collect(); + + assert_eq!(dsl_defs.len(), 1); + let args = dsl_defs[0].arguments(); + + let arg_list = args.arguments(); + assert_eq!(arg_list.len(), 5); + + // First argument is a constant reference (Parent) + let DslArgument::Positional(v) = &arg_list[0] else { + panic!("expected Positional") + }; + let ref_id = v.as_reference().expect("expected constant reference for Parent"); + let ref_entry = context.graph().constant_references().get(&ref_id).unwrap(); + let name = context.graph().names().get(ref_entry.name_id()).unwrap(); + let name_str = context.graph().strings().get(name.str()).unwrap(); + assert_eq!(name_str.as_str(), "Parent"); + + // Second argument is a string literal + let DslArgument::Positional(v) = &arg_list[1] else { + panic!("expected Positional") + }; + assert_eq!(v.as_str().unwrap(), "\"string_arg\""); + + let DslArgument::Splat(s) = &arg_list[2] else { + panic!("expected Splat") + }; + assert_eq!(s, "splat_args"); + + let DslArgument::KeywordArg { key, value } = &arg_list[3] else { + panic!("expected KeywordArg") + }; + assert_eq!(key, "key"); + assert_eq!(value.as_str().unwrap(), "value"); + + let DslArgument::DoubleSplat(s) = &arg_list[4] else { + panic!("expected DoubleSplat") + }; + assert_eq!(s, "double_splat"); + + assert_block_offset_eq!(&context, "1:74-2:4", args); + } + + #[test] + fn index_dsl_captures_block_argument() { + // &proc is treated as a block by Prism, not as an argument + let context = index_source({ + " + proc = -> { } + Class.new(&proc) # Class.new is captured as DSL + " + }); + assert_no_diagnostics!(&context); + + let dsl_defs: Vec<_> = context + .graph() + .definitions() + .iter() + .filter_map(|(_, def)| match def { + Definition::Dsl(d) => Some(d), + _ => None, + }) + .collect(); + + assert_eq!(dsl_defs.len(), 1); + let args = dsl_defs[0].arguments(); + + assert!(args.arguments().is_empty()); + assert_block_offset_eq!(&context, "2:11-2:16", args); + } } diff --git a/rust/rubydex/src/model.rs b/rust/rubydex/src/model.rs index 35090a354..b2df075f6 100644 --- a/rust/rubydex/src/model.rs +++ b/rust/rubydex/src/model.rs @@ -2,6 +2,8 @@ pub mod comment; pub mod declaration; pub mod definitions; pub mod document; +pub mod dsl; +pub mod dsl_processors; pub mod encoding; pub mod graph; pub mod id; diff --git a/rust/rubydex/src/model/definitions.rs b/rust/rubydex/src/model/definitions.rs index 4e0cc091a..6f655c554 100644 --- a/rust/rubydex/src/model/definitions.rs +++ b/rust/rubydex/src/model/definitions.rs @@ -29,6 +29,7 @@ use crate::{ assert_mem_size, model::{ comment::Comment, + dsl::DslArgumentList, ids::{DefinitionId, NameId, ReferenceId, StringId, UriId}, visibility::Visibility, }, @@ -112,6 +113,9 @@ pub enum Definition { ClassVariable(Box), MethodAlias(Box), GlobalVariableAlias(Box), + Dsl(Box), + DynamicClass(Box), + DynamicModule(Box), } assert_mem_size!(Definition, 16); @@ -132,6 +136,9 @@ macro_rules! all_definitions { Definition::Method($var) => $expr, Definition::MethodAlias($var) => $expr, Definition::GlobalVariableAlias($var) => $expr, + Definition::Dsl($var) => $expr, + Definition::DynamicClass($var) => $expr, + Definition::DynamicModule($var) => $expr, } }; } @@ -144,12 +151,12 @@ impl Definition { #[must_use] pub fn uri_id(&self) -> &UriId { - all_definitions!(self, it => &it.uri_id()) + all_definitions!(self, it => it.uri_id()) } #[must_use] pub fn offset(&self) -> &Offset { - all_definitions!(self, it => &it.offset()) + all_definitions!(self, it => it.offset()) } #[must_use] @@ -159,7 +166,7 @@ impl Definition { #[must_use] pub fn lexical_nesting_id(&self) -> &Option { - all_definitions!(self, it => &it.lexical_nesting_id()) + all_definitions!(self, it => it.lexical_nesting_id()) } #[must_use] @@ -179,6 +186,9 @@ impl Definition { Definition::ClassVariable(_) => "ClassVariable", Definition::MethodAlias(_) => "AliasMethod", Definition::GlobalVariableAlias(_) => "GlobalVariableAlias", + Definition::Dsl(_) => "Dsl", + Definition::DynamicClass(_) => "DynamicClass", + Definition::DynamicModule(_) => "DynamicModule", } } @@ -189,6 +199,8 @@ impl Definition { Definition::SingletonClass(d) => Some(d.name_id()), Definition::Module(d) => Some(d.name_id()), Definition::Constant(d) => Some(d.name_id()), + Definition::DynamicClass(d) => d.name_id(), + Definition::DynamicModule(d) => d.name_id(), _ => None, } } @@ -743,6 +755,18 @@ impl ConstantAliasDefinition { } } +/// Represents the receiver of a singleton method definition. +/// +/// - `SelfReceiver`: `def self.foo` - the enclosing class/module/DSL definition +/// - `ConstantReceiver`: `def Foo.bar` - an explicit constant reference +#[derive(Debug, Clone, Copy)] +pub enum Receiver { + /// `def self.foo` - receiver is the enclosing definition (class, module, or DSL) + SelfReceiver(DefinitionId), + /// `def Foo.bar` - receiver is an explicit constant that needs resolution + ConstantReceiver(NameId), +} + /// A method definition /// /// # Example @@ -760,7 +784,7 @@ pub struct MethodDefinition { lexical_nesting_id: Option, parameters: Vec, visibility: Visibility, - receiver: Option, + receiver: Option, } assert_mem_size!(MethodDefinition, 88); @@ -776,7 +800,7 @@ impl MethodDefinition { lexical_nesting_id: Option, parameters: Vec, visibility: Visibility, - receiver: Option, + receiver: Option, ) -> Self { Self { str_id, @@ -795,8 +819,11 @@ impl MethodDefinition { pub fn id(&self) -> DefinitionId { let mut formatted_id = format!("{}{}{}", *self.uri_id, self.offset.start(), *self.str_id); - if let Some(receiver) = self.receiver { - formatted_id.push_str(&receiver.to_string()); + if let Some(receiver) = &self.receiver { + match receiver { + Receiver::SelfReceiver(def_id) => formatted_id.push_str(&def_id.to_string()), + Receiver::ConstantReceiver(name_id) => formatted_id.push_str(&name_id.to_string()), + } } let mut id = DefinitionId::from(&formatted_id); @@ -835,8 +862,8 @@ impl MethodDefinition { } #[must_use] - pub fn receiver(&self) -> &Option { - &self.receiver + pub fn receiver(&self) -> Option { + self.receiver } #[must_use] @@ -1527,3 +1554,369 @@ impl GlobalVariableAliasDefinition { &self.flags } } + +/// A DSL definition captures a DSL method call (e.g., `Class.new`, `Module.new`). +/// +/// This is created during indexing when a DSL call is detected. The receiver is +/// captured as a `NameId` which will be resolved during the resolution phase. +/// +/// # Example +/// ```ruby +/// Foo = Class.new do +/// def bar; end +/// end +/// ``` +/// +/// Creates a `DslDefinition` with: +/// - `receiver_name`: `NameId(Class)` (to be resolved to `::Class`) +/// - `method_name`: `StringId("new")` +/// - `members`: `[MethodDefinition(bar)]` +/// - `assigned_to`: points to `ConstantDefinition` for `Foo` +#[derive(Debug)] +pub struct DslDefinition { + /// The receiver of the DSL call (e.g., `Class` in `Class.new`). + /// `None` for DSLs called without a receiver (e.g., `attr_accessor`). + receiver_name: Option, + /// The method name of the DSL call (e.g., `"new"`) + method_name: StringId, + /// The arguments passed to the DSL method + arguments: DslArgumentList, + uri_id: UriId, + offset: Offset, + /// The lexical parent of this DSL definition (enclosing Class/Module). + lexical_nesting_id: Option, + /// Definitions that are owned by this DSL block (e.g., methods defined inside) + members: Vec, + /// Mixins (include, prepend, extend) within this DSL block + mixins: Vec, + /// Reference to the `ConstantDefinition` if this DSL is assigned to a constant. + /// E.g., for `Foo = Class.new`, this points to the `ConstantDefinition` for `Foo`. + assigned_to: Option, +} + +impl DslDefinition { + #[must_use] + pub const fn new( + receiver_name: Option, + method_name: StringId, + arguments: DslArgumentList, + uri_id: UriId, + offset: Offset, + lexical_nesting_id: Option, + assigned_to: Option, + ) -> Self { + Self { + receiver_name, + method_name, + arguments, + uri_id, + offset, + lexical_nesting_id, + members: Vec::new(), + mixins: Vec::new(), + assigned_to, + } + } + + #[must_use] + pub fn id(&self) -> DefinitionId { + let receiver_str = self.receiver_name.map_or(String::from("None"), |id| id.to_string()); + DefinitionId::from(&format!( + "{}{}{}{}", + *self.uri_id, + self.offset.start(), + receiver_str, + *self.method_name + )) + } + + #[must_use] + pub fn receiver_name(&self) -> Option { + self.receiver_name + } + + #[must_use] + pub fn method_name(&self) -> &StringId { + &self.method_name + } + + #[must_use] + pub fn arguments(&self) -> &DslArgumentList { + &self.arguments + } + + #[must_use] + pub fn uri_id(&self) -> &UriId { + &self.uri_id + } + + #[must_use] + pub fn offset(&self) -> &Offset { + &self.offset + } + + #[must_use] + pub fn comments(&self) -> &[Comment] { + // DSL definitions don't have their own comments; those are on the ConstantDefinition + &[] + } + + #[must_use] + pub fn lexical_nesting_id(&self) -> &Option { + &self.lexical_nesting_id + } + + #[must_use] + pub fn members(&self) -> &[DefinitionId] { + &self.members + } + + pub fn add_member(&mut self, member_id: DefinitionId) { + self.members.push(member_id); + } + + #[must_use] + pub fn mixins(&self) -> &[Mixin] { + &self.mixins + } + + pub fn add_mixin(&mut self, mixin: Mixin) { + self.mixins.push(mixin); + } + + /// Returns the `DefinitionId` of the `ConstantDefinition` if this DSL is assigned to a constant. + #[must_use] + pub fn assigned_to(&self) -> Option { + self.assigned_to + } + + /// Sets the constant this DSL is assigned to. + pub fn set_assigned_to(&mut self, id: DefinitionId) { + self.assigned_to = Some(id); + } + + #[must_use] + pub fn flags(&self) -> &DefinitionFlags { + // DSL definitions don't have flags + static EMPTY_FLAGS: DefinitionFlags = DefinitionFlags::empty(); + &EMPTY_FLAGS + } +} + +/// A dynamically created class definition from `Class.new`. +/// +/// This is created during DSL processing (Phase 3) when a `Class.new` call +/// is recognized and processed by the handler. +/// +/// # Example +/// ```ruby +/// Foo = Class.new do +/// def bar; end +/// end +/// ``` +/// +/// After DSL processing, creates a `DynamicClassDefinition` that references +/// the underlying `DslDefinition` by ID and adds resolution-specific data. +#[derive(Debug)] +pub struct DynamicClassDefinition { + /// Reference to the underlying DSL definition by ID + dsl_definition_id: DefinitionId, + /// The resolved name of the class (reparented if nested), or `None` for anonymous classes + name_id: Option, + /// Reference to the superclass if specified + superclass_ref: Option, + /// Comments from the constant assignment (if any) + comments: Vec, + /// Flags from the constant assignment (if any) + flags: DefinitionFlags, + // Copied from DSL for convenience (avoids needing graph access for common operations) + uri_id: UriId, + offset: Offset, + lexical_nesting_id: Option, + mixins: Vec, +} + +impl DynamicClassDefinition { + #[must_use] + #[allow(clippy::too_many_arguments)] + pub fn new( + dsl_definition_id: DefinitionId, + name_id: Option, + superclass_ref: Option, + comments: Vec, + flags: DefinitionFlags, + uri_id: UriId, + offset: Offset, + lexical_nesting_id: Option, + mixins: Vec, + ) -> Self { + Self { + dsl_definition_id, + name_id, + superclass_ref, + comments, + flags, + uri_id, + offset, + lexical_nesting_id, + mixins, + } + } + + #[must_use] + pub fn id(&self) -> DefinitionId { + DefinitionId::from(&format!("{}DynamicClass", *self.dsl_definition_id)) + } + + /// Computes the `DynamicClass` ID that would be created from a given DSL definition ID. + /// This allows direct lookup without iteration. + #[must_use] + pub fn id_for_dsl(dsl_definition_id: DefinitionId) -> DefinitionId { + DefinitionId::from(&format!("{}DynamicClass", *dsl_definition_id)) + } + + #[must_use] + pub fn dsl_definition_id(&self) -> DefinitionId { + self.dsl_definition_id + } + + #[must_use] + pub fn name_id(&self) -> Option<&NameId> { + self.name_id.as_ref() + } + + #[must_use] + pub fn superclass_ref(&self) -> Option<&ReferenceId> { + self.superclass_ref.as_ref() + } + + #[must_use] + pub fn uri_id(&self) -> &UriId { + &self.uri_id + } + + #[must_use] + pub fn offset(&self) -> &Offset { + &self.offset + } + + #[must_use] + pub fn comments(&self) -> &[Comment] { + &self.comments + } + + #[must_use] + pub fn lexical_nesting_id(&self) -> &Option { + &self.lexical_nesting_id + } + + #[must_use] + pub fn mixins(&self) -> &[Mixin] { + &self.mixins + } + + #[must_use] + pub fn flags(&self) -> &DefinitionFlags { + &self.flags + } +} + +/// A dynamically created module definition from `Module.new`. +/// +/// This is created during DSL processing (Phase 3) when a `Module.new` call +/// is recognized and processed by the handler. It references the underlying +/// `DslDefinition` by ID and adds resolution-specific data. +#[derive(Debug)] +pub struct DynamicModuleDefinition { + /// Reference to the underlying DSL definition by ID + dsl_definition_id: DefinitionId, + /// The resolved name of the module (reparented if nested), or `None` for anonymous modules + name_id: Option, + /// Comments from the constant assignment (if any) + comments: Vec, + /// Flags from the constant assignment (if any) + flags: DefinitionFlags, + // Copied from DSL for convenience (avoids needing graph access for common operations) + uri_id: UriId, + offset: Offset, + lexical_nesting_id: Option, + mixins: Vec, +} + +impl DynamicModuleDefinition { + #[must_use] + #[allow(clippy::too_many_arguments)] + pub fn new( + dsl_definition_id: DefinitionId, + name_id: Option, + comments: Vec, + flags: DefinitionFlags, + uri_id: UriId, + offset: Offset, + lexical_nesting_id: Option, + mixins: Vec, + ) -> Self { + Self { + dsl_definition_id, + name_id, + comments, + flags, + uri_id, + offset, + lexical_nesting_id, + mixins, + } + } + + #[must_use] + pub fn id(&self) -> DefinitionId { + DefinitionId::from(&format!("{}DynamicModule", *self.dsl_definition_id)) + } + + /// Computes the `DynamicModule` ID that would be created from a given DSL definition ID. + /// This allows direct lookup without iteration. + #[must_use] + pub fn id_for_dsl(dsl_definition_id: DefinitionId) -> DefinitionId { + DefinitionId::from(&format!("{}DynamicModule", *dsl_definition_id)) + } + + #[must_use] + pub fn dsl_definition_id(&self) -> DefinitionId { + self.dsl_definition_id + } + + #[must_use] + pub fn name_id(&self) -> Option<&NameId> { + self.name_id.as_ref() + } + + #[must_use] + pub fn uri_id(&self) -> &UriId { + &self.uri_id + } + + #[must_use] + pub fn offset(&self) -> &Offset { + &self.offset + } + + #[must_use] + pub fn comments(&self) -> &[Comment] { + &self.comments + } + + #[must_use] + pub fn lexical_nesting_id(&self) -> &Option { + &self.lexical_nesting_id + } + + #[must_use] + pub fn mixins(&self) -> &[Mixin] { + &self.mixins + } + + #[must_use] + pub fn flags(&self) -> &DefinitionFlags { + &self.flags + } +} diff --git a/rust/rubydex/src/model/dsl.rs b/rust/rubydex/src/model/dsl.rs new file mode 100644 index 000000000..3735e8361 --- /dev/null +++ b/rust/rubydex/src/model/dsl.rs @@ -0,0 +1,114 @@ +//! DSL-related types for capturing DSL method calls during indexing. + +use crate::model::ids::ReferenceId; +use crate::offset::Offset; + +/// Represents a value in a DSL argument. +/// Can be either a raw string or a resolved constant reference. +#[derive(Debug, Clone)] +pub enum DslValue { + /// A raw string value (e.g., `"123"`, `some_var`) + String(String), + /// A constant reference (e.g., `Foo`, `Bar::Baz`) + Reference(ReferenceId), +} + +impl DslValue { + /// Returns the value as a string if it is a `String` variant. + #[must_use] + pub fn as_str(&self) -> Option<&str> { + match self { + Self::String(s) => Some(s.as_str()), + Self::Reference(_) => None, + } + } + + /// Returns the reference ID if it is a `Reference` variant. + #[must_use] + pub fn as_reference(&self) -> Option { + match self { + Self::Reference(id) => Some(*id), + Self::String(_) => None, + } + } +} + +/// Represents a single argument in a DSL call. +#[derive(Debug, Clone)] +pub enum DslArgument { + /// A positional argument (e.g., `Parent` in `Class.new(Parent)`) + Positional(DslValue), + /// A keyword argument (e.g., `class_name: "User"`) + KeywordArg { key: String, value: DslValue }, + /// A splat argument (e.g., `*args`) + Splat(String), + /// A double splat argument (e.g., `**kwargs`) + DoubleSplat(String), + /// A block argument (e.g., `&block`) + BlockArg(String), +} + +/// Represents the arguments passed to a DSL method call. +#[derive(Debug, Clone, Default)] +pub struct DslArgumentList { + arguments: Vec, + block_offset: Option, +} + +impl DslArgumentList { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + pub fn add(&mut self, arg: DslArgument) { + self.arguments.push(arg); + } + + pub fn set_block_offset(&mut self, offset: Offset) { + self.block_offset = Some(offset); + } + + #[must_use] + pub fn arguments(&self) -> &[DslArgument] { + &self.arguments + } + + #[must_use] + pub fn block_offset(&self) -> Option<&Offset> { + self.block_offset.as_ref() + } + + #[must_use] + pub fn has_block(&self) -> bool { + self.block_offset.is_some() + } + + /// Returns the first positional argument as a reference ID if it is a constant reference. + #[must_use] + pub fn first_positional_reference(&self) -> Option { + self.arguments.iter().find_map(|arg| match arg { + DslArgument::Positional(value) => value.as_reference(), + _ => None, + }) + } + + #[must_use] + pub fn positional_args(&self) -> Vec<&DslValue> { + self.arguments + .iter() + .filter_map(|arg| match arg { + DslArgument::Positional(v) => Some(v), + _ => None, + }) + .collect() + } + + #[must_use] + pub fn keyword_arg(&self, key: &str) -> Option<&DslValue> { + self.arguments.iter().find_map(|arg| match arg { + DslArgument::KeywordArg { key: k, value } if k == key => Some(value), + _ => None, + }) + } +} diff --git a/rust/rubydex/src/model/dsl_processors.rs b/rust/rubydex/src/model/dsl_processors.rs new file mode 100644 index 000000000..1c159e18e --- /dev/null +++ b/rust/rubydex/src/model/dsl_processors.rs @@ -0,0 +1,224 @@ +//! DSL processor types for matching and handling specific DSL patterns. + +use crate::model::comment::Comment; +use crate::model::definitions::{Definition, DefinitionFlags, DynamicClassDefinition, DynamicModuleDefinition, Mixin}; +use crate::model::graph::{CLASS_ID, Graph, MODULE_ID}; +use crate::model::ids::{DeclarationId, DefinitionId, NameId, UriId}; +use crate::model::name::{Name, ParentScope}; +use crate::offset::Offset; + +/// Type alias for DSL processor handle function. +/// Processes the DSL and mutates the graph accordingly. +/// +/// Parameters: +/// - `graph`: The graph to mutate +/// - `dsl_def_id`: The DSL definition ID being processed +/// +/// Returns the `DefinitionId` of any newly created definition. +pub type DslHandleFn = fn(graph: &mut Graph, dsl_def_id: DefinitionId) -> Option; + +/// A DSL processor that can match and handle specific DSL patterns. +/// +/// The `method_name` field is used for pre-filtering during indexing. +/// The `matches` function performs full matching after resolution, when the receiver has been +/// resolved to a `DeclarationId`. +#[derive(Clone, Copy, Debug)] +pub struct DslProcessor { + /// The method name this processor is interested in (e.g., "new") + pub method_name: &'static str, + /// Returns true if this processor handles the given receiver/method combination + pub matches: fn(receiver_decl_id: Option) -> bool, + /// The handler function for this DSL pattern + pub handle: DslHandleFn, +} + +/// Matches `Class.new` DSL calls. +#[must_use] +pub fn class_new_matches(receiver_decl_id: Option) -> bool { + receiver_decl_id == Some(*CLASS_ID) +} + +/// Matches `Module.new` DSL calls. +#[must_use] +pub fn module_new_matches(receiver_decl_id: Option) -> bool { + receiver_decl_id == Some(*MODULE_ID) +} + +/// Common data extracted from a DSL definition and its associated constant. +struct DslContext { + uri_id: UriId, + offset: Offset, + lexical_nesting_id: Option, + mixins: Vec, + constant_def_id: Option, + name_id: Option, + comments: Vec, + flags: DefinitionFlags, +} + +/// Gets the reparented name from a processed parent DSL (`DynamicClass` or `DynamicModule`). +/// Uses the deterministic ID format to look up directly without iteration. +/// Returns None if the parent hasn't been processed yet or has no name. +fn get_processed_parent_name(graph: &Graph, parent_dsl_id: DefinitionId) -> Option { + // Try DynamicClass first (more common) + let dynamic_class_id = DynamicClassDefinition::id_for_dsl(parent_dsl_id); + if let Some(Definition::DynamicClass(dc)) = graph.definitions().get(&dynamic_class_id) { + return dc.name_id().copied(); + } + + // Try DynamicModule + let dynamic_module_id = DynamicModuleDefinition::id_for_dsl(parent_dsl_id); + if let Some(Definition::DynamicModule(dm)) = graph.definitions().get(&dynamic_module_id) { + return dm.name_id().copied(); + } + + None +} + +/// Returns true if the parent DSL has been processed (converted to DynamicClass/Module). +/// Uses the deterministic ID format to check directly without iteration. +#[must_use] +pub fn is_parent_dsl_processed(graph: &Graph, parent_dsl_id: DefinitionId) -> bool { + let dynamic_class_id = DynamicClassDefinition::id_for_dsl(parent_dsl_id); + if graph.definitions().contains_key(&dynamic_class_id) { + return true; + } + + let dynamic_module_id = DynamicModuleDefinition::id_for_dsl(parent_dsl_id); + graph.definitions().contains_key(&dynamic_module_id) +} + +/// Extracts common data from a DSL definition and computes the reparented name. +fn extract_dsl_context(graph: &mut Graph, dsl_def_id: DefinitionId) -> Option { + let (uri_id, offset, lexical_nesting_id, mixins, constant_def_id) = { + let Definition::Dsl(dsl_def) = graph.definitions().get(&dsl_def_id)? else { + return None; + }; + ( + *dsl_def.uri_id(), + dsl_def.offset().clone(), + *dsl_def.lexical_nesting_id(), + dsl_def.mixins().to_vec(), + dsl_def.assigned_to(), + ) + }; + + let (original_name_id, comments, flags, parent_dsl_id) = constant_def_id + .and_then(|id| { + let Definition::Constant(c) = graph.definitions().get(&id)? else { + return None; + }; + Some(( + Some(*c.name_id()), + c.comments().to_vec(), + c.flags().clone(), + *c.lexical_nesting_id(), + )) + }) + .unwrap_or_else(|| (None, Vec::new(), DefinitionFlags::empty(), None)); + + // Eagerly reparent the name if nested inside another DynamicClass/Module + // Parent must already be processed (depth-first ordering guarantees this) + let name_id = match (original_name_id, parent_dsl_id) { + (Some(orig_name), Some(parent_id)) => { + if let Some(parent_name_id) = get_processed_parent_name(graph, parent_id) { + Some(create_reparented_name(graph, orig_name, parent_name_id)) + } else { + Some(orig_name) + } + } + (Some(orig_name), None) => Some(orig_name), + (None, _) => None, + }; + + Some(DslContext { + uri_id, + offset, + lexical_nesting_id, + mixins, + constant_def_id, + name_id, + comments, + flags, + }) +} + +/// Creates a reparented name by setting the parent name. +fn create_reparented_name(graph: &mut Graph, original_name_id: NameId, parent_name_id: NameId) -> NameId { + let name_ref = graph.names().get(&original_name_id).expect("name must exist"); + let new_name = Name::new(*name_ref.str(), ParentScope::Some(parent_name_id), *name_ref.nesting()); + let new_name_id = new_name.id(); + graph.add_name(new_name); + new_name_id +} + +/// Handles `Class.new` DSL calls by creating a `DynamicClassDefinition`. +/// +/// Creates a dynamic class definition from the DSL, reparenting names if nested. +/// The original `ConstantDefinition` is removed after creating the dynamic definition. +pub fn handle_class_new(graph: &mut Graph, dsl_def_id: DefinitionId) -> Option { + // Get superclass reference from DSL arguments before extracting context + let superclass_ref = { + let Definition::Dsl(dsl_def) = graph.definitions().get(&dsl_def_id)? else { + return None; + }; + dsl_def.arguments().first_positional_reference() + }; + + let ctx = extract_dsl_context(graph, dsl_def_id)?; + + let dynamic_class = DynamicClassDefinition::new( + dsl_def_id, + ctx.name_id, + superclass_ref, + ctx.comments, + ctx.flags, + ctx.uri_id, + ctx.offset, + ctx.lexical_nesting_id, + ctx.mixins, + ); + + let definition_id = dynamic_class.id(); + graph + .definitions_mut() + .insert(definition_id, Definition::DynamicClass(Box::new(dynamic_class))); + + if let Some(id) = ctx.constant_def_id { + graph.definitions_mut().remove(&id); + Some(definition_id) + } else { + None + } +} + +/// Handles `Module.new` DSL calls by creating a `DynamicModuleDefinition`. +/// +/// Creates a dynamic module definition from the DSL, reparenting names if nested. +/// The original `ConstantDefinition` is removed after creating the dynamic definition. +pub fn handle_module_new(graph: &mut Graph, dsl_def_id: DefinitionId) -> Option { + let ctx = extract_dsl_context(graph, dsl_def_id)?; + + let dynamic_module = DynamicModuleDefinition::new( + dsl_def_id, + ctx.name_id, + ctx.comments, + ctx.flags, + ctx.uri_id, + ctx.offset, + ctx.lexical_nesting_id, + ctx.mixins, + ); + + let definition_id = dynamic_module.id(); + graph + .definitions_mut() + .insert(definition_id, Definition::DynamicModule(Box::new(dynamic_module))); + + if let Some(id) = ctx.constant_def_id { + graph.definitions_mut().remove(&id); + Some(definition_id) + } else { + None + } +} diff --git a/rust/rubydex/src/model/graph.rs b/rust/rubydex/src/model/graph.rs index 116cff0ab..0881f89e4 100644 --- a/rust/rubydex/src/model/graph.rs +++ b/rust/rubydex/src/model/graph.rs @@ -6,6 +6,9 @@ use crate::indexing::local_graph::LocalGraph; use crate::model::declaration::{Ancestor, Declaration, Namespace}; use crate::model::definitions::Definition; use crate::model::document::Document; +use crate::model::dsl_processors::{ + DslProcessor, class_new_matches, handle_class_new, handle_module_new, module_new_matches, +}; use crate::model::encoding::Encoding; use crate::model::identity_maps::{IdentityHashMap, IdentityHashSet}; use crate::model::ids::{DeclarationId, DefinitionId, NameId, ReferenceId, StringId, UriId}; @@ -40,12 +43,15 @@ pub struct Graph { /// The position encoding used for LSP line/column locations. Not related to the actual encoding of the file position_encoding: Encoding, + + /// Registry of DSL processors that handle specific DSL patterns. + dsl_processors: Vec, } impl Graph { #[must_use] pub fn new() -> Self { - Self { + let mut graph = Self { declarations: IdentityHashMap::default(), definitions: IdentityHashMap::default(), documents: IdentityHashMap::default(), @@ -54,7 +60,40 @@ impl Graph { constant_references: IdentityHashMap::default(), method_references: IdentityHashMap::default(), position_encoding: Encoding::default(), - } + dsl_processors: Vec::new(), + }; + + // Register built-in DSL processors + graph.register_dsl_processor(DslProcessor { + method_name: "new", + matches: class_new_matches, + handle: handle_class_new, + }); + graph.register_dsl_processor(DslProcessor { + method_name: "new", + matches: module_new_matches, + handle: handle_module_new, + }); + + graph + } + + /// Returns the DSL method names based on registered processors. + /// Used to initialize indexers with the set of DSL patterns to capture. + #[must_use] + pub fn dsl_method_names(&self) -> Vec<&'static str> { + self.dsl_processors.iter().map(|p| p.method_name).collect() + } + + /// Registers a DSL processor in the graph. + pub fn register_dsl_processor(&mut self, processor: DslProcessor) { + self.dsl_processors.push(processor); + } + + /// Returns the registered DSL processors. + #[must_use] + pub fn dsl_processors(&self) -> &[DslProcessor] { + &self.dsl_processors } // Returns an immutable reference to the declarations map @@ -87,6 +126,12 @@ impl Graph { &self.definitions } + /// Returns a mutable reference to the definitions map + #[must_use] + pub fn definitions_mut(&mut self) -> &mut IdentityHashMap { + &mut self.definitions + } + /// Returns the ID of the unqualified name of a definition /// /// # Panics @@ -124,6 +169,25 @@ impl Graph { Definition::Method(it) => it.str_id(), Definition::MethodAlias(it) => it.new_name_str_id(), Definition::GlobalVariableAlias(it) => it.new_name_str_id(), + Definition::Dsl(it) => it.method_name(), + Definition::DynamicClass(it) => { + if let Some(name_id) = it.name_id() { + let name = self.names.get(name_id).unwrap(); + name.str() + } else { + // Anonymous dynamic class - use a placeholder + return StringId::from(""); + } + } + Definition::DynamicModule(it) => { + if let Some(name_id) = it.name_id() { + let name = self.names.get(name_id).unwrap(); + name.str() + } else { + // Anonymous dynamic module - use a placeholder + return StringId::from(""); + } + } }; *id @@ -171,63 +235,73 @@ impl Graph { return self.name_id_to_declaration_id(*it.name_id()); } Definition::GlobalVariable(it) => { - let nesting_definition = it - .lexical_nesting_id() - .and_then(|id| self.definitions().get(&id).unwrap().name_id()); - (nesting_definition, it.str_id()) + let nesting_name_id = it.lexical_nesting_id().and_then(|id| self.nesting_name_id(id)); + (nesting_name_id, it.str_id()) } Definition::GlobalVariableAlias(it) => { - let nesting_definition = it - .lexical_nesting_id() - .and_then(|id| self.definitions().get(&id).unwrap().name_id()); - (nesting_definition, it.new_name_str_id()) + let nesting_name_id = it.lexical_nesting_id().and_then(|id| self.nesting_name_id(id)); + (nesting_name_id, it.new_name_str_id()) } Definition::InstanceVariable(it) => { - let nesting_definition = it - .lexical_nesting_id() - .and_then(|id| self.definitions().get(&id).unwrap().name_id()); - (nesting_definition, it.str_id()) + let nesting_name_id = it.lexical_nesting_id().and_then(|id| self.nesting_name_id(id)); + (nesting_name_id, it.str_id()) } Definition::ClassVariable(it) => { - let nesting_definition = it - .lexical_nesting_id() - .and_then(|id| self.definitions().get(&id).unwrap().name_id()); - (nesting_definition, it.str_id()) + let nesting_name_id = it.lexical_nesting_id().and_then(|id| self.nesting_name_id(id)); + (nesting_name_id, it.str_id()) } Definition::AttrAccessor(it) => { - let nesting_definition = it - .lexical_nesting_id() - .and_then(|id| self.definitions().get(&id).unwrap().name_id()); - (nesting_definition, it.str_id()) + let nesting_name_id = it.lexical_nesting_id().and_then(|id| self.nesting_name_id(id)); + (nesting_name_id, it.str_id()) } Definition::AttrReader(it) => { - let nesting_definition = it - .lexical_nesting_id() - .and_then(|id| self.definitions().get(&id).unwrap().name_id()); - (nesting_definition, it.str_id()) + let nesting_name_id = it.lexical_nesting_id().and_then(|id| self.nesting_name_id(id)); + (nesting_name_id, it.str_id()) } Definition::AttrWriter(it) => { - let nesting_definition = it - .lexical_nesting_id() - .and_then(|id| self.definitions().get(&id).unwrap().name_id()); - (nesting_definition, it.str_id()) + let nesting_name_id = it.lexical_nesting_id().and_then(|id| self.nesting_name_id(id)); + (nesting_name_id, it.str_id()) } Definition::Method(it) => { - let nesting_definition = it - .lexical_nesting_id() - .and_then(|id| self.definitions().get(&id).unwrap().name_id()); - (nesting_definition, it.str_id()) + let nesting_name_id = it.lexical_nesting_id().and_then(|id| self.nesting_name_id(id)); + (nesting_name_id, it.str_id()) } Definition::MethodAlias(it) => { - let nesting_definition = it - .lexical_nesting_id() - .and_then(|id| self.definitions().get(&id).unwrap().name_id()); - (nesting_definition, it.new_name_str_id()) + let nesting_name_id = it.lexical_nesting_id().and_then(|id| self.nesting_name_id(id)); + (nesting_name_id, it.new_name_str_id()) + } + Definition::Dsl(dsl) => { + // DSL definitions don't have direct declarations, but if a DynamicClass/DynamicModule + // was created from this DSL, we can return that declaration. + let dsl_id = dsl.id(); + for def in self.definitions().values() { + match def { + Definition::DynamicClass(d) if d.dsl_definition_id() == dsl_id => { + return d.name_id().and_then(|name_id| self.name_id_to_declaration_id(*name_id)); + } + Definition::DynamicModule(d) if d.dsl_definition_id() == dsl_id => { + return d.name_id().and_then(|name_id| self.name_id_to_declaration_id(*name_id)); + } + _ => {} + } + } + // No dynamic definition found yet - DSL hasn't been processed + return None; + } + Definition::DynamicClass(it) => { + return it + .name_id() + .and_then(|name_id| self.name_id_to_declaration_id(*name_id)); + } + Definition::DynamicModule(it) => { + return it + .name_id() + .and_then(|name_id| self.name_id_to_declaration_id(*name_id)); } }; let nesting_declaration_id = match nesting_name_id { - Some(name_id) => self.name_id_to_declaration_id(*name_id), + Some(name_id) => self.name_id_to_declaration_id(name_id), None => Some(&*OBJECT_ID), }?; @@ -249,6 +323,39 @@ impl Graph { } } + /// Returns the `name_id` for a nesting definition. + /// + /// For most definitions, this returns `definition.name_id()`. + /// For `DslDefinition`, this looks up the corresponding `DynamicClassDefinition` or + /// `DynamicModuleDefinition` and returns its `name_id`. + #[must_use] + fn nesting_name_id(&self, definition_id: DefinitionId) -> Option { + let definition = self.definitions().get(&definition_id)?; + + // First, try getting the name_id directly from the definition + if let Some(name_id) = definition.name_id() { + return Some(*name_id); + } + + // For DslDefinition, look up the corresponding DynamicClass/DynamicModule + if let Definition::Dsl(_) = definition { + // Find a DynamicClass or DynamicModule that references this DSL + for def in self.definitions().values() { + match def { + Definition::DynamicClass(d) if d.dsl_definition_id() == definition_id => { + return d.name_id().copied(); + } + Definition::DynamicModule(d) if d.dsl_definition_id() == definition_id => { + return d.name_id().copied(); + } + _ => {} + } + } + } + + None + } + // Returns an immutable reference to the constant references map #[must_use] pub fn constant_references(&self) -> &IdentityHashMap { @@ -385,7 +492,9 @@ impl Graph { | Definition::SingletonClass(_) | Definition::Module(_) | Definition::Constant(_) - | Definition::ConstantAlias(_) => {} + | Definition::ConstantAlias(_) + | Definition::DynamicClass(_) + | Definition::DynamicModule(_) => {} Definition::Method(d) => self.untrack_string(*d.str_id()), Definition::AttrAccessor(d) => self.untrack_string(*d.str_id()), Definition::AttrReader(d) => self.untrack_string(*d.str_id()), @@ -401,6 +510,7 @@ impl Graph { self.untrack_string(*d.new_name_str_id()); self.untrack_string(*d.old_name_str_id()); } + Definition::Dsl(d) => self.untrack_string(*d.method_name()), } } diff --git a/rust/rubydex/src/resolution.rs b/rust/rubydex/src/resolution.rs index 96e85c4cb..692268090 100644 --- a/rust/rubydex/src/resolution.rs +++ b/rust/rubydex/src/resolution.rs @@ -1,5 +1,5 @@ use std::{ - collections::{HashSet, VecDeque}, + collections::{HashMap, HashSet, VecDeque}, hash::BuildHasher, }; @@ -9,7 +9,8 @@ use crate::model::{ Declaration, GlobalVariableDeclaration, InstanceVariableDeclaration, MethodDeclaration, ModuleDeclaration, Namespace, SingletonClassDeclaration, }, - definitions::{Definition, Mixin}, + definitions::{Definition, DynamicClassDefinition, DynamicModuleDefinition, Mixin, Receiver}, + dsl_processors::is_parent_dsl_processed, graph::{CLASS_ID, Graph, MODULE_ID, OBJECT_ID}, identity_maps::{IdentityHashMap, IdentityHashSet}, ids::{DeclarationId, DefinitionId, NameId, ReferenceId, StringId}, @@ -23,6 +24,8 @@ pub enum Unit { ConstantRef(ReferenceId), /// A list of ancestors that have been partially linearized and need to be retried Ancestors(DeclarationId), + /// A DSL definition that needs to be processed once its receiver is resolved + Dsl(DefinitionId), } enum Outcome { @@ -88,6 +91,10 @@ impl<'a> Resolver<'a> { /// 3. Resolution of all constant references /// 4. Inheritance relationships between declarations /// + /// The resolution loop processes definitions, references, ancestors, and DSLs in a unified pass. + /// DSLs are processed opportunistically as their receivers become resolved, allowing a single + /// loop to handle all resolution work. + /// /// # Panics /// /// Can panic if there's inconsistent data in the graph @@ -95,32 +102,7 @@ impl<'a> Resolver<'a> { // TODO: temporary code while we don't have synchronization. We clear all declarations instead of doing the minimal // amount of work self.graph.clear_declarations(); - // Ensure that Object exists ahead of time so that we can associate top level declarations with the right membership - - { - self.graph.declarations_mut().insert( - *OBJECT_ID, - Declaration::Namespace(Namespace::Class(Box::new(ClassDeclaration::new( - "Object".to_string(), - *OBJECT_ID, - )))), - ); - self.graph.declarations_mut().insert( - *MODULE_ID, - Declaration::Namespace(Namespace::Class(Box::new(ClassDeclaration::new( - "Module".to_string(), - *OBJECT_ID, - )))), - ); - self.graph.declarations_mut().insert( - *CLASS_ID, - Declaration::Namespace(Namespace::Class(Box::new(ClassDeclaration::new( - "Class".to_string(), - *OBJECT_ID, - )))), - ); - } - + self.initialize_builtin_declarations(); let other_ids = self.prepare_units(); loop { @@ -137,14 +119,13 @@ impl<'a> Resolver<'a> { }; match unit_id { - Unit::Definition(id) => { - self.handle_definition_unit(unit_id, id); - } - Unit::ConstantRef(id) => { - self.handle_reference_unit(unit_id, id); - } - Unit::Ancestors(id) => { - self.handle_ancestor_unit(id); + Unit::Definition(id) => self.handle_definition_unit(unit_id, id), + Unit::ConstantRef(id) => self.handle_reference_unit(unit_id, id), + Unit::Ancestors(id) => self.handle_ancestor_unit(id), + Unit::Dsl(id) => { + if let Some(new_def_id) = self.handle_dsl_unit(id) { + self.unit_queue.push_back(Unit::Definition(new_def_id)); + } } } } @@ -157,6 +138,37 @@ impl<'a> Resolver<'a> { self.handle_remaining_definitions(other_ids); } + fn initialize_builtin_declarations(&mut self) { + self.graph.declarations_mut().insert( + *OBJECT_ID, + Declaration::Namespace(Namespace::Class(Box::new(ClassDeclaration::new( + "Object".to_string(), + *OBJECT_ID, + )))), + ); + self.graph.declarations_mut().insert( + *MODULE_ID, + Declaration::Namespace(Namespace::Class(Box::new(ClassDeclaration::new( + "Module".to_string(), + *OBJECT_ID, + )))), + ); + self.graph.declarations_mut().insert( + *CLASS_ID, + Declaration::Namespace(Namespace::Class(Box::new(ClassDeclaration::new( + "Class".to_string(), + *OBJECT_ID, + )))), + ); + + let object_str_id = self.graph.intern_string("Object".to_string()); + let module_str_id = self.graph.intern_string("Module".to_string()); + let class_str_id = self.graph.intern_string("Class".to_string()); + self.graph.add_member(&OBJECT_ID, *OBJECT_ID, object_str_id); + self.graph.add_member(&OBJECT_ID, *MODULE_ID, module_str_id); + self.graph.add_member(&OBJECT_ID, *CLASS_ID, class_str_id); + } + /// Resolves a single constant against the graph. This method is not meant to be used by the resolution phase, but by /// the Ruby API pub fn resolve_constant(&mut self, name_id: NameId) -> Option { @@ -180,14 +192,34 @@ impl<'a> Resolver<'a> { }) } Definition::Constant(constant) => { - self.handle_constant_declaration(*constant.name_id(), id, false, |name, owner_id| { - Declaration::Constant(Box::new(ConstantDeclaration::new(name, owner_id))) - }) + let name_id = *constant.name_id(); + let lexical_nesting_id = *constant.lexical_nesting_id(); + if let Some(outcome) = + self.maybe_handle_constant_inside_dsl(name_id, id, lexical_nesting_id, |name, owner_id| { + Declaration::Constant(Box::new(ConstantDeclaration::new(name, owner_id))) + }) + { + outcome + } else { + self.handle_constant_declaration(name_id, id, false, |name, owner_id| { + Declaration::Constant(Box::new(ConstantDeclaration::new(name, owner_id))) + }) + } } Definition::ConstantAlias(alias) => { - self.handle_constant_declaration(*alias.name_id(), id, false, |name, owner_id| { - Declaration::ConstantAlias(Box::new(ConstantAliasDeclaration::new(name, owner_id))) - }) + let name_id = *alias.name_id(); + let lexical_nesting_id = *alias.lexical_nesting_id(); + if let Some(outcome) = + self.maybe_handle_constant_inside_dsl(name_id, id, lexical_nesting_id, |name, owner_id| { + Declaration::ConstantAlias(Box::new(ConstantAliasDeclaration::new(name, owner_id))) + }) + { + outcome + } else { + self.handle_constant_declaration(name_id, id, false, |name, owner_id| { + Declaration::ConstantAlias(Box::new(ConstantAliasDeclaration::new(name, owner_id))) + }) + } } Definition::SingletonClass(singleton) => { self.handle_constant_declaration(*singleton.name_id(), id, true, |name, owner_id| { @@ -196,16 +228,41 @@ impl<'a> Resolver<'a> { )))) }) } + Definition::DynamicClass(dynamic_class) => { + // Only handle named dynamic classes + if let Some(name_id) = dynamic_class.name_id() { + self.handle_constant_declaration(*name_id, id, false, |name, owner_id| { + Declaration::Namespace(Namespace::Class(Box::new(ClassDeclaration::new(name, owner_id)))) + }) + } else { + // Anonymous dynamic class - no declaration needed + Outcome::Resolved(*OBJECT_ID, None) + } + } + Definition::DynamicModule(dynamic_module) => { + // Only handle named dynamic modules + if let Some(name_id) = dynamic_module.name_id() { + self.handle_constant_declaration(*name_id, id, false, |name, owner_id| { + Declaration::Namespace(Namespace::Module(Box::new(ModuleDeclaration::new(name, owner_id)))) + }) + } else { + // Anonymous dynamic module - no declaration needed + Outcome::Resolved(*OBJECT_ID, None) + } + } _ => panic!("Expected constant definitions"), }; match outcome { Outcome::Retry(None) => { - // There might be dependencies we haven't figured out yet, so we need to retry + // There might be dependencies we haven't figured out yet (e.g., DSL not processed), + // so we need to retry. The resolution loop will break when no progress is made. self.unit_queue.push_back(unit_id); } Outcome::Unresolved(None) => { - // We couldn't resolve this name. Emit a diagnostic + // We couldn't resolve this name. It might be created later by DSL processing, + // so we retry. The resolution loop will break when no progress is made. + self.unit_queue.push_back(unit_id); } Outcome::Retry(Some(id_needing_linearization)) | Outcome::Unresolved(Some(id_needing_linearization)) => { self.unit_queue.push_back(unit_id); @@ -265,7 +322,7 @@ impl<'a> Resolver<'a> { } } - /// Handle other definitions that don't require resolution, but need to have their declarations and membership created + /// Handle other definitions that don't require resolution, but need to have their declarations and membership created. #[allow(clippy::too_many_lines)] fn handle_remaining_definitions(&mut self, other_ids: Vec) { for id in other_ids { @@ -273,22 +330,42 @@ impl<'a> Resolver<'a> { Definition::Method(method_definition) => { let str_id = *method_definition.str_id(); let owner_id = if let Some(receiver) = method_definition.receiver() { - let receiver_decl_id = match self.graph.names().get(receiver).unwrap() { - NameRef::Resolved(resolved) => *resolved.declaration_id(), - NameRef::Unresolved(_) => { - // Error diagnostic: if we couldn't resolve the constant being used, then we don't know - // where this method is being defined. For example: - // - // def Foo.bar; end - // - // where Foo is undefined - continue; + let receiver_decl_id = match receiver { + Receiver::SelfReceiver(def_id) => { + // def self.foo - get declaration from the enclosing definition + match self + .graph + .definitions() + .get(&def_id) + .and_then(|def| self.graph.definition_to_declaration_id(def).copied()) + { + Some(decl_id) => decl_id, + None => { + // Definition hasn't been resolved yet, skip + continue; + } + } + } + Receiver::ConstantReceiver(name_id) => { + // def Foo.bar - resolve the constant name + match self.graph.names().get(&name_id).unwrap() { + NameRef::Resolved(resolved) => *resolved.declaration_id(), + NameRef::Unresolved(_) => { + // Error diagnostic: if we couldn't resolve the constant being used, + // then we don't know where this method is being defined. + // For example: def Foo.bar; end where Foo is undefined + continue; + } + } } }; self.get_or_create_singleton_class(receiver_decl_id) } else { - self.resolve_lexical_owner(*method_definition.lexical_nesting_id()) + let Some(owner_id) = self.resolve_lexical_owner(*method_definition.lexical_nesting_id()) else { + continue; // Owner not resolved yet (e.g., DSL not processed) + }; + owner_id }; self.create_declaration(str_id, id, owner_id, |name| { @@ -296,21 +373,27 @@ impl<'a> Resolver<'a> { }); } Definition::AttrAccessor(attr) => { - let owner_id = self.resolve_lexical_owner(*attr.lexical_nesting_id()); + let Some(owner_id) = self.resolve_lexical_owner(*attr.lexical_nesting_id()) else { + continue; + }; self.create_declaration(*attr.str_id(), id, owner_id, |name| { Declaration::Method(Box::new(MethodDeclaration::new(name, owner_id))) }); } Definition::AttrReader(attr) => { - let owner_id = self.resolve_lexical_owner(*attr.lexical_nesting_id()); + let Some(owner_id) = self.resolve_lexical_owner(*attr.lexical_nesting_id()) else { + continue; + }; self.create_declaration(*attr.str_id(), id, owner_id, |name| { Declaration::Method(Box::new(MethodDeclaration::new(name, owner_id))) }); } Definition::AttrWriter(attr) => { - let owner_id = self.resolve_lexical_owner(*attr.lexical_nesting_id()); + let Some(owner_id) = self.resolve_lexical_owner(*attr.lexical_nesting_id()) else { + continue; + }; self.create_declaration(*attr.str_id(), id, owner_id, |name| { Declaration::Method(Box::new(MethodDeclaration::new(name, owner_id))) @@ -345,105 +428,107 @@ impl<'a> Resolver<'a> { // When the instance variable is inside a method body, we determine the owner based on the method's receiver Definition::Method(method) => { // Method has explicit receiver (def self.foo or def Foo.bar) - if let Some(receiver_name_id) = method.receiver() { - let Some(NameRef::Resolved(resolved)) = self.graph.names().get(receiver_name_id) else { - // TODO: add diagnostic for unresolved receiver - continue; + if let Some(receiver) = method.receiver() { + let receiver_decl_id = match receiver { + Receiver::SelfReceiver(def_id) => { + match self + .graph + .definitions() + .get(&def_id) + .and_then(|def| self.graph.definition_to_declaration_id(def).copied()) + { + Some(decl_id) => decl_id, + None => continue, + } + } + Receiver::ConstantReceiver(name_id) => { + let Some(NameRef::Resolved(resolved)) = self.graph.names().get(&name_id) else { + // TODO: add diagnostic for unresolved receiver + continue; + }; + *resolved.declaration_id() + } }; - let receiver_decl_id = *resolved.declaration_id(); // Instance variable in singleton method - owned by the receiver's singleton class let owner_id = self.get_or_create_singleton_class(receiver_decl_id); - { - debug_assert!( - matches!( - self.graph.declarations().get(&owner_id), - Some(Declaration::Namespace(Namespace::SingletonClass(_))) - ), - "Instance variable in singleton method should be owned by a SingletonClass" - ); - } - self.create_declaration(str_id, id, owner_id, |name| { - Declaration::InstanceVariable(Box::new(InstanceVariableDeclaration::new( - name, owner_id, - ))) - }); + self.create_instance_variable_declaration( + str_id, + id, + owner_id, + "Instance variable in singleton method should be owned by a SingletonClass", + ); continue; } // If the method has no explicit receiver, we resolve the owner based on the lexical nesting - let method_owner_id = self.resolve_lexical_owner(*method.lexical_nesting_id()); + let Some(method_owner_id) = self.resolve_lexical_owner(*method.lexical_nesting_id()) else { + continue; + }; - // If the method is in a singleton class, the instance variable belongs to the class object - // Like `class << Foo; def bar; @bar = 1; end; end`, where `@bar` is owned by `Foo::` - if let Some(decl) = self.graph.declarations().get(&method_owner_id) - && matches!(decl, Declaration::Namespace(Namespace::SingletonClass(_))) - { - // Method in singleton class - owner is the singleton class itself - self.create_declaration(str_id, id, method_owner_id, |name| { - Declaration::InstanceVariable(Box::new(InstanceVariableDeclaration::new( - name, - method_owner_id, - ))) - }); - } else { - // Regular instance method - // Create an instance variable declaration for the method's owner - self.create_declaration(str_id, id, method_owner_id, |name| { - Declaration::InstanceVariable(Box::new(InstanceVariableDeclaration::new( - name, - method_owner_id, - ))) - }); - } - } - // If the instance variable is directly in a class/module body, it belongs to the class object - // and is owned by the singleton class of that class/module - Definition::Class(_) | Definition::Module(_) => { - let nesting_decl_id = self - .graph - .definition_id_to_declaration_id(nesting_id) - .copied() - .unwrap_or(*OBJECT_ID); - let owner_id = self.get_or_create_singleton_class(nesting_decl_id); - { - debug_assert!( - matches!( - self.graph.declarations().get(&owner_id), - Some(Declaration::Namespace(Namespace::SingletonClass(_))) - ), - "Instance variable in class/module body should be owned by a SingletonClass" - ); - } - self.create_declaration(str_id, id, owner_id, |name| { + // Instance variables in methods belong to the method's owner + // For methods in singleton classes, that's the singleton class itself + self.create_declaration(str_id, id, method_owner_id, |name| { Declaration::InstanceVariable(Box::new(InstanceVariableDeclaration::new( - name, owner_id, + name, + method_owner_id, ))) }); } - // If in a singleton class body directly, the owner is the singleton class's singleton class - // Like `class << Foo; @bar = 1; end`, where `@bar` is owned by `Foo::::<>` - Definition::SingletonClass(_) => { - let singleton_class_decl_id = self + // Instance variable in class/module/singleton_class body - owned by the singleton class + // For `class Foo; @bar = 1; end`, @bar is owned by `Foo::` + // For `class << Foo; @bar = 1; end`, @bar is owned by `Foo::::<>` + Definition::Class(_) + | Definition::Module(_) + | Definition::DynamicClass(_) + | Definition::DynamicModule(_) + | Definition::SingletonClass(_) => { + let nesting_decl_id = self .graph .definition_id_to_declaration_id(nesting_id) .copied() .unwrap_or(*OBJECT_ID); - let owner_id = self.get_or_create_singleton_class(singleton_class_decl_id); - { - debug_assert!( - matches!( - self.graph.declarations().get(&owner_id), - Some(Declaration::Namespace(Namespace::SingletonClass(_))) - ), - "Instance variable in singleton class body should be owned by a SingletonClass" + let owner_id = self.get_or_create_singleton_class(nesting_decl_id); + self.create_instance_variable_declaration( + str_id, + id, + owner_id, + "Instance variable in class/module body should be owned by a SingletonClass", + ); + } + // DSL nesting - if it created a namespace (Class.new/Module.new), use its singleton class. + // For anonymous Class.new/Module.new, the ivar belongs to the anonymous class (no declaration). + // For non-namespace DSLs (e.g., Bar.new), fall back to enclosing class/module. + Definition::Dsl(dsl) => { + // Extract lexical_nesting_id before calling mutable methods + let dsl_parent_nesting_id = *dsl.lexical_nesting_id(); + + if let Some(decl_id) = self.get_dsl_namespace_declaration(nesting_id) { + // Named Class.new/Module.new - use its singleton class + let owner_id = self.get_or_create_singleton_class(decl_id); + self.create_instance_variable_declaration( + str_id, + id, + owner_id, + "Instance variable in DSL block should be owned by a SingletonClass", + ); + } else if is_parent_dsl_processed(self.graph, nesting_id) { + // Anonymous Class.new/Module.new - ivar belongs to anonymous class, skip + } else if let Some(parent_nesting_id) = dsl_parent_nesting_id { + // Non-namespace DSL (e.g., Bar.new) - fall back to enclosing class/module + let nesting_decl_id = self + .graph + .definition_id_to_declaration_id(parent_nesting_id) + .copied() + .unwrap_or(*OBJECT_ID); + let owner_id = self.get_or_create_singleton_class(nesting_decl_id); + self.create_instance_variable_declaration( + str_id, + id, + owner_id, + "Instance variable in non-namespace DSL should be owned by enclosing singleton class", ); } - self.create_declaration(str_id, id, owner_id, |name| { - Declaration::InstanceVariable(Box::new(InstanceVariableDeclaration::new( - name, owner_id, - ))) - }); } _ => { panic!("Unexpected lexical nesting for instance variable: {nesting_def:?}"); @@ -459,16 +544,23 @@ impl<'a> Resolver<'a> { } } Definition::MethodAlias(alias) => { - let owner_id = self.resolve_lexical_owner(*alias.lexical_nesting_id()); + let Some(owner_id) = self.resolve_lexical_owner(*alias.lexical_nesting_id()) else { + continue; + }; self.create_declaration(*alias.new_name_str_id(), id, owner_id, |name| { Declaration::Method(Box::new(MethodDeclaration::new(name, owner_id))) }); } Definition::GlobalVariableAlias(alias) => { - self.create_declaration(*alias.new_name_str_id(), id, *OBJECT_ID, |name| { + let str_id = *alias.new_name_str_id(); + let name = self.graph.strings().get(&str_id).unwrap().as_str().to_string(); + let declaration_id = DeclarationId::from(&name); + + self.create_declaration(str_id, id, *OBJECT_ID, |name| { Declaration::GlobalVariable(Box::new(GlobalVariableDeclaration::new(name, *OBJECT_ID))) }); + self.graph.add_member(&OBJECT_ID, declaration_id, str_id); } Definition::Class(_) | Definition::SingletonClass(_) @@ -477,6 +569,9 @@ impl<'a> Resolver<'a> { | Definition::ConstantAlias(_) => { panic!("Unexpected definition type in non-constant resolution. This shouldn't happen") } + // DSL definitions are handled in a separate phase after initial resolution. + // Dynamic class/module definitions are created during DSL processing. + Definition::Dsl(_) | Definition::DynamicClass(_) | Definition::DynamicModule(_) => {} } } } @@ -503,6 +598,26 @@ impl<'a> Resolver<'a> { self.graph.add_member(&owner_id, declaration_id, str_id); } + /// Creates an instance variable declaration with debug assertion for singleton class ownership. + fn create_instance_variable_declaration( + &mut self, + str_id: StringId, + definition_id: DefinitionId, + owner_id: DeclarationId, + debug_message: &str, + ) { + debug_assert!( + matches!( + self.graph.declarations().get(&owner_id), + Some(Declaration::Namespace(Namespace::SingletonClass(_))) + ), + "{debug_message}" + ); + self.create_declaration(str_id, definition_id, owner_id, |name| { + Declaration::InstanceVariable(Box::new(InstanceVariableDeclaration::new(name, owner_id))) + }); + } + /// Resolves owner for class variables, bypassing singleton classes. fn resolve_class_variable_owner(&self, lexical_nesting_id: Option) -> Option { let mut current_nesting = lexical_nesting_id; @@ -518,32 +633,55 @@ impl<'a> Resolver<'a> { current_nesting.and_then(|id| self.graph.definition_id_to_declaration_id(id).copied()) } + /// Looks up the declaration for a `DynamicClass` or `DynamicModule` created from a DSL. + /// Returns `None` if the DSL hasn't been processed yet. + fn get_dsl_namespace_declaration(&self, dsl_def_id: DefinitionId) -> Option { + // Try DynamicClass first (more common) + let dynamic_class_id = DynamicClassDefinition::id_for_dsl(dsl_def_id); + if self.graph.definitions().contains_key(&dynamic_class_id) + && let Some(decl_id) = self.graph.definition_id_to_declaration_id(dynamic_class_id) + { + return Some(*decl_id); + } + + // Try DynamicModule + let dynamic_module_id = DynamicModuleDefinition::id_for_dsl(dsl_def_id); + if self.graph.definitions().contains_key(&dynamic_module_id) { + return self.graph.definition_id_to_declaration_id(dynamic_module_id).copied(); + } + + None + } + /// Resolves owner from lexical nesting. - fn resolve_lexical_owner(&self, lexical_nesting_id: Option) -> DeclarationId { + /// Returns `None` if the owner can't be resolved yet (e.g., DSL not processed). + fn resolve_lexical_owner(&self, lexical_nesting_id: Option) -> Option { let Some(id) = lexical_nesting_id else { - return *OBJECT_ID; + return Some(*OBJECT_ID); }; + let definition = self.graph.definitions().get(&id).unwrap(); + + // If the definition is a DSL, resolve to its DynamicClass/DynamicModule declaration. + if matches!(definition, Definition::Dsl(_)) { + return self.get_dsl_namespace_declaration(id); + } + // If no declaration exists yet for this definition, walk up the lexical chain. - // This handles the case where attr_* definitions inside methods are processed - // before the method definition itself. let Some(declaration_id) = self.graph.definition_id_to_declaration_id(id) else { - let definition = self.graph.definitions().get(&id).unwrap(); return self.resolve_lexical_owner(*definition.lexical_nesting_id()); }; let declarations = self.graph.declarations(); - // If the associated declaration is a namespace that can own things, we found the right owner. Otherwise, we might - // have found something nested inside something else (like a method), in which case we have to recurse until we find - // the appropriate owner + // If the declaration is a namespace that can own things, we found the owner. + // Otherwise, recurse to find the enclosing namespace. if matches!( declarations.get(declaration_id).unwrap(), Declaration::Namespace(Namespace::Class(_) | Namespace::Module(_) | Namespace::SingletonClass(_)) ) { - *declaration_id + Some(*declaration_id) } else { - let definition = self.graph.definitions().get(&id).unwrap(); self.resolve_lexical_owner(*definition.lexical_nesting_id()) } } @@ -952,6 +1090,61 @@ impl<'a> Resolver<'a> { } } + /// Handles constants/aliases defined directly inside a DSL block (e.g., `Foo = Class.new { CONST = 1 }`). + /// Returns `Some(Outcome)` if handled, `None` if this should fall through to normal resolution. + fn maybe_handle_constant_inside_dsl( + &mut self, + name_id: NameId, + definition_id: DefinitionId, + lexical_nesting_id: Option, + declaration_builder: F, + ) -> Option + where + F: FnOnce(String, DeclarationId) -> Declaration, + { + // Only applies to constants with a lexical nesting + let nesting_id = lexical_nesting_id?; + + // Only handle constants directly inside a DSL block + // Regular class/module nesting is handled by `handle_constant_declaration` + if !matches!(self.graph.definitions().get(&nesting_id), Some(Definition::Dsl(_))) { + return None; + } + + // Only handle simple names without explicit parent scope + // `CONST = 1` → handled here (owner = DSL's declaration) + // `Foo::CONST = 1` → not handled here (owner determined by `Foo::`) + let name_ref = self.graph.names().get(&name_id).unwrap(); + if !matches!(name_ref.parent_scope(), ParentScope::None) { + return None; + } + + // Get the DSL's declaration as the owner + let Some(owner_id) = self.graph.definition_id_to_declaration_id(nesting_id).copied() else { + // DSL not yet resolved, retry later + return Some(Outcome::Retry(None)); + }; + + // Build fully qualified name: "Owner::ConstName" + let str_id = *name_ref.str(); + let mut fully_qualified_name = self.graph.strings().get(&str_id).unwrap().to_string(); + let owner = self.graph.declarations().get(&owner_id).unwrap(); + if owner_id != *OBJECT_ID { + fully_qualified_name.insert_str(0, "::"); + fully_qualified_name.insert_str(0, owner.name()); + } + + // Create declaration and register + let declaration_id = DeclarationId::from(&fully_qualified_name); + self.graph.add_member(&owner_id, declaration_id, str_id); + self.graph.add_declaration(declaration_id, definition_id, || { + declaration_builder(fully_qualified_name, owner_id) + }); + self.graph.record_resolved_name(name_id, declaration_id); + + Some(Outcome::Resolved(declaration_id, None)) + } + // Returns the owner declaration ID for a given name. If the name is simple and has no parent scope, then the owner is // either the nesting or Object. If the name has a parent scope, we attempt to resolve the reference and that should be // the name's owner. For aliases, resolves through to get the actual namespace. @@ -1286,9 +1479,11 @@ impl<'a> Resolver<'a> { } } - /// Returns a complexity score for a given name, which is used to sort names for resolution. The complexity is based - /// on how many parent scopes are involved in a name's nesting. This is because simple names are always - /// straightforward to resolve no matter how deep the nesting is. For example: + /// Pre-computes name depths for all names to avoid repeated recursive calls during sorting. + /// + /// The complexity score is based on how many parent scopes are involved in a name's nesting. + /// Simple names are always straightforward to resolve no matter how deep the nesting is. + /// For example: /// /// ```ruby /// module Foo @@ -1298,83 +1493,147 @@ impl<'a> Resolver<'a> { /// end /// ``` /// - /// These are all simple names because they don't require resolution logic to determine the final name of each step. - /// We only have to ensure that they are ordered by nesting level. Names with parent scopes require that their parts - /// be resolved to determine what they refer to and so they must be sorted last. + /// These are all simple names because they don't require resolution logic to determine the + /// final name of each step. We only have to ensure that they are ordered by nesting level. + /// Names with parent scopes require that their parts be resolved to determine what they + /// refer to and so they must be sorted last. /// /// ```ruby /// module Foo /// module Bar::Baz /// class Qux; end - /// end + /// end /// end /// ``` /// - /// In this case, we need `Bar` to have already been processed so that we can resolve the `Bar` reference inside of - /// the `Foo` nesting, which then unblocks the resolution of `Baz` and finally `Qux`. Notice how `Qux` is a simple - /// name, but it's nested under a complex name so we have to sort it last. This is why we consider the number of - /// parent scopes in the entire nesting, not just for the name itself + /// In this case, we need `Bar` to have already been processed so that we can resolve the + /// `Bar` reference inside of the `Foo` nesting, which then unblocks the resolution of `Baz` + /// and finally `Qux`. Notice how `Qux` is a simple name, but it's nested under a complex + /// name so we have to sort it last. This is why we consider the number of parent scopes in + /// the entire nesting, not just for the name itself. /// - /// # Panics - /// - /// Will panic if there is inconsistent data in the graph - fn name_depth(name: &NameRef, names: &IdentityHashMap) -> u32 { - if name.parent_scope().is_top_level() { - return 1; + /// Returns a `HashMap` mapping `NameId` to its computed depth. + #[must_use] + pub fn compute_all_name_depths(names: &IdentityHashMap) -> HashMap { + fn compute_depth( + name_id: NameId, + names: &IdentityHashMap, + depths: &mut HashMap, + ) -> u32 { + // Return cached value if already computed + if let Some(&depth) = depths.get(&name_id) { + return depth; + } + + let Some(name) = names.get(&name_id) else { + return 1; + }; + + if name.parent_scope().is_top_level() { + depths.insert(name_id, 1); + return 1; + } + + let parent_depth = name.parent_scope().map_or(0, |id| compute_depth(*id, names, depths)); + let nesting_depth = name.nesting().map_or(0, |id| compute_depth(id, names, depths)); + let depth = parent_depth + nesting_depth + 1; + + depths.insert(name_id, depth); + depth } - let parent_depth = name.parent_scope().map_or(0, |id| { - let name_ref = names.get(id).unwrap(); - Self::name_depth(name_ref, names) - }); + let mut depths: HashMap = HashMap::with_capacity(names.len()); - let nesting_depth = name.nesting().map_or(0, |id| { - let name_ref = names.get(&id).unwrap(); - Self::name_depth(name_ref, names) - }); + // Compute depth for all names + for &name_id in names.keys() { + compute_depth(name_id, names, &mut depths); + } - parent_depth + nesting_depth + 1 + depths } + #[allow(clippy::too_many_lines)] fn prepare_units(&mut self) -> Vec { let estimated_length = self.graph.definitions().len() / 2; let mut definitions = Vec::with_capacity(estimated_length); let mut others = Vec::with_capacity(estimated_length); let names = self.graph.names(); - for (id, definition) in self.graph.definitions() { - let uri = self.graph.documents().get(definition.uri_id()).unwrap().uri(); + // Build set of ConstantDefinition IDs that are linked to DSLs. + // These should NOT be processed here - they'll be handled by handle_dsl_unit + // which will either create DynamicClass/DynamicModule (removing the ConstantDefinition) + // or leave the ConstantDefinition to be processed in the resolution loop. + let dsl_assigned_constants: HashSet = self + .graph + .definitions() + .values() + .filter_map(|def| { + if let Definition::Dsl(dsl) = def { + dsl.assigned_to() + } else { + None + } + }) + .collect(); + + // Pre-compute name depths for all names (avoiding repeated recursive calls during sorting) + let name_depths = Self::compute_all_name_depths(names); + // Helper to get precomputed depth for a name_id + let get_depth = |name_id: &NameId| -> u32 { name_depths.get(name_id).copied().unwrap_or(1) }; + + for (id, definition) in self.graph.definitions() { match definition { Definition::Class(def) => { - definitions.push(( - Unit::Definition(*id), - (names.get(def.name_id()).unwrap(), uri, definition.offset()), - )); + let uri = self.graph.documents().get(def.uri_id()).unwrap().uri(); + let depth = get_depth(def.name_id()); + definitions.push((Unit::Definition(*id), (depth, uri, def.offset()))); } Definition::Module(def) => { - definitions.push(( - Unit::Definition(*id), - (names.get(def.name_id()).unwrap(), uri, definition.offset()), - )); + let uri = self.graph.documents().get(def.uri_id()).unwrap().uri(); + let depth = get_depth(def.name_id()); + definitions.push((Unit::Definition(*id), (depth, uri, def.offset()))); } Definition::Constant(def) => { - definitions.push(( - Unit::Definition(*id), - (names.get(def.name_id()).unwrap(), uri, definition.offset()), - )); + // Skip constants that are linked to DSLs - they're handled during DSL processing. + // DSL processing will either remove them (if a handler matches) or they will be + // resolved in the main resolution loop. + if dsl_assigned_constants.contains(id) { + continue; + } + let uri = self.graph.documents().get(def.uri_id()).unwrap().uri(); + let depth = get_depth(def.name_id()); + definitions.push((Unit::Definition(*id), (depth, uri, def.offset()))); } Definition::ConstantAlias(def) => { - definitions.push(( - Unit::Definition(*id), - (names.get(def.name_id()).unwrap(), uri, definition.offset()), - )); + let uri = self.graph.documents().get(def.uri_id()).unwrap().uri(); + let depth = get_depth(def.name_id()); + definitions.push((Unit::Definition(*id), (depth, uri, def.offset()))); } Definition::SingletonClass(def) => { - definitions.push(( - Unit::Definition(*id), - (names.get(def.name_id()).unwrap(), uri, definition.offset()), - )); + let uri = self.graph.documents().get(def.uri_id()).unwrap().uri(); + let depth = get_depth(def.name_id()); + definitions.push((Unit::Definition(*id), (depth, uri, def.offset()))); + } + Definition::DynamicClass(def) => { + // Only include named dynamic classes (anonymous ones don't create declarations) + if let Some(name_id) = def.name_id() { + let uri = self.graph.documents().get(def.uri_id()).unwrap().uri(); + let depth = get_depth(name_id); + definitions.push((Unit::Definition(*id), (depth, uri, def.offset()))); + } else { + others.push(*id); + } + } + Definition::DynamicModule(def) => { + // Only include named dynamic modules (anonymous ones don't create declarations) + if let Some(name_id) = def.name_id() { + let uri = self.graph.documents().get(def.uri_id()).unwrap().uri(); + let depth = get_depth(name_id); + definitions.push((Unit::Definition(*id), (depth, uri, def.offset()))); + } else { + others.push(*id); + } } _ => { others.push(*id); @@ -1384,38 +1643,50 @@ impl<'a> Resolver<'a> { // Sort namespaces based on their name complexity so that simpler names are always first // When the depth is the same, sort by URI and offset to maintain determinism - definitions.sort_by(|(_, (name_a, uri_a, offset_a)), (_, (name_b, uri_b, offset_b))| { - (Self::name_depth(name_a, names), uri_a, offset_a).cmp(&(Self::name_depth(name_b, names), uri_b, offset_b)) - }); + definitions.sort_by(|(_, sort_key_a), (_, sort_key_b)| sort_key_a.cmp(sort_key_b)); - let mut const_refs = self + let mut references: Vec<_> = self .graph .constant_references() .iter() .map(|(id, constant_ref)| { let uri = self.graph.documents().get(&constant_ref.uri_id()).unwrap().uri(); - - ( - Unit::ConstantRef(*id), - (names.get(constant_ref.name_id()).unwrap(), uri, constant_ref.offset()), - ) + let depth = get_depth(constant_ref.name_id()); + (Unit::ConstantRef(*id), (depth, uri, constant_ref.offset())) }) - .collect::>(); + .collect(); // Sort constant references based on their name complexity so that simpler names are always first - const_refs.sort_by(|(_, (name_a, uri_a, offset_a)), (_, (name_b, uri_b, offset_b))| { - (Self::name_depth(name_a, names), uri_a, offset_a).cmp(&(Self::name_depth(name_b, names), uri_b, offset_b)) - }); + references.sort_by(|a, b| a.1.cmp(&b.1)); self.unit_queue .extend(definitions.into_iter().map(|(id, _)| id).collect::>()); self.unit_queue - .extend(const_refs.into_iter().map(|(id, _)| id).collect::>()); + .extend(references.into_iter().map(|(id, _)| id).collect::>()); + + // Collect DSL definitions for the unified loop + // Sort by nesting depth so parents are processed before children + let mut dsl_units: Vec<_> = self + .graph + .definitions() + .iter() + .filter_map(|(id, def)| match def { + Definition::Dsl(_) => Some(*id), + _ => None, + }) + .collect(); + + dsl_units.sort_by_key(|id| Self::dsl_nesting_depth(*id, self.graph.definitions())); + + // Add DSL units to the queue (after definitions and references) + self.unit_queue.extend(dsl_units.into_iter().map(Unit::Dsl)); others.shrink_to_fit(); others } + /// Returns the sorted units for resolution and the remaining definition IDs. + /// This is a wrapper around `prepare_units()` that returns the queue. /// Returns the singleton parent ID for an attached object ID. A singleton class' parent depends on what the attached /// object is: /// @@ -1462,13 +1733,18 @@ impl<'a> Resolver<'a> { for definition_id in definition_ids { let definition = self.graph.definitions().get(definition_id).unwrap(); - if let Definition::Class(class) = definition - && let Some(superclass) = class.superclass_ref() - { + // Extract superclass_ref from either Class or DynamicClass definitions + let superclass_ref = match definition { + Definition::Class(class) => class.superclass_ref().copied(), + Definition::DynamicClass(dynamic_class) => dynamic_class.superclass_ref().copied(), + _ => None, + }; + + if let Some(superclass) = superclass_ref { let name = self .graph .names() - .get(self.graph.constant_references().get(superclass).unwrap().name_id()) + .get(self.graph.constant_references().get(&superclass).unwrap().name_id()) .unwrap(); match name { @@ -1504,9 +1780,112 @@ impl<'a> Resolver<'a> { Definition::Class(class) => Some(class.mixins().to_vec()), Definition::SingletonClass(class) => Some(class.mixins().to_vec()), Definition::Module(module) => Some(module.mixins().to_vec()), + Definition::DynamicClass(dynamic_class) => Some(dynamic_class.mixins().to_vec()), + Definition::DynamicModule(dynamic_module) => Some(dynamic_module.mixins().to_vec()), _ => None, } } + + /// Calculates the nesting depth of a DSL definition. + /// Used to sort DSLs so parents are processed before children. + fn dsl_nesting_depth(dsl_def_id: DefinitionId, definitions: &IdentityHashMap) -> u32 { + let Some(Definition::Dsl(dsl_def)) = definitions.get(&dsl_def_id) else { + return 0; + }; + + let Some(constant_def_id) = dsl_def.assigned_to() else { + return 0; // No constant assignment + }; + + let Some(Definition::Constant(constant_def)) = definitions.get(&constant_def_id) else { + return 0; // Shouldn't happen, but handle gracefully + }; + + let Some(nesting_id) = constant_def.lexical_nesting_id() else { + return 0; // Top-level + }; + + if matches!(definitions.get(nesting_id), Some(Definition::Dsl(_))) { + 1 + Self::dsl_nesting_depth(*nesting_id, definitions) + } else { + 0 // Parent is not a DSL + } + } + + /// Attempts to process a DSL definition within the unified resolution loop. + /// + /// DSLs are processed opportunistically when their receiver class/module has been resolved. + /// If the receiver is not yet resolved, or if this DSL is nested inside another unprocessed DSL, + /// the DSL unit is pushed back onto the queue for retry. + /// + /// Returns `Some(new_definition_id)` if the DSL was processed successfully, `None` if it should retry later. + fn handle_dsl_unit(&mut self, dsl_def_id: DefinitionId) -> Option { + use crate::model::dsl_processors::is_parent_dsl_processed; + + let Some(Definition::Dsl(dsl_def)) = self.graph.definitions().get(&dsl_def_id) else { + return None; + }; + let assigned_to = dsl_def.assigned_to(); + let receiver_name = dsl_def.receiver_name(); + let method_name_str_id = *dsl_def.method_name(); + + // Check if this DSL is nested inside another unprocessed DSL + let parent_dsl_id = assigned_to.and_then(|const_id| { + let Definition::Constant(c) = self.graph.definitions().get(&const_id)? else { + return None; + }; + let nesting_id = c.lexical_nesting_id().as_ref()?; + if matches!(self.graph.definitions().get(nesting_id), Some(Definition::Dsl(_))) { + Some(*nesting_id) + } else { + None + } + }); + + if let Some(parent_id) = parent_dsl_id + && !is_parent_dsl_processed(self.graph, parent_id) + { + self.unit_queue.push_back(Unit::Dsl(dsl_def_id)); + return None; + } + + let receiver_decl_id = receiver_name.and_then(|name_id| match self.graph.names().get(&name_id) { + Some(NameRef::Resolved(resolved)) => Some(*resolved.declaration_id()), + _ => None, + }); + + let method_name = self + .graph + .strings() + .get(&method_name_str_id) + .map(|s| s.as_str().to_string()) + .unwrap_or_default(); + + let matching_processor = self + .graph + .dsl_processors() + .iter() + .find(|p| p.method_name == method_name && (p.matches)(receiver_decl_id)) + .copied(); + + match matching_processor { + Some(processor) => { + let result = (processor.handle)(self.graph, dsl_def_id); + if result.is_some() { + self.made_progress = true; + } + result + } + None => { + if receiver_decl_id.is_some() { + assigned_to + } else { + self.unit_queue.push_back(Unit::Dsl(dsl_def_id)); + None + } + } + } + } } #[cfg(test)] @@ -1785,10 +2164,15 @@ mod tests { " }); - let mut names = context.graph().names().values().collect::>(); + // Collect (NameId, NameRef) pairs + let mut names: Vec<_> = context.graph().names().iter().map(|(id, name)| (*id, name)).collect(); assert_eq!(10, names.len()); - names.sort_by_key(|a| Resolver::name_depth(a, context.graph().names())); + // Pre-compute all name depths + let name_depths = Resolver::compute_all_name_depths(context.graph().names()); + + // Sort by precomputed depth + names.sort_by_key(|(id, _)| name_depths.get(id).copied().unwrap_or(1)); assert_eq!( [ @@ -1796,7 +2180,7 @@ mod tests { ], names .iter() - .map(|n| context.graph().strings().get(n.str()).unwrap().as_str()) + .map(|(_, n)| context.graph().strings().get(n.str()).unwrap().as_str()) .collect::>() .as_slice() ); @@ -2340,7 +2724,7 @@ mod tests { assert_no_diagnostics!(&context); - assert_members_eq!(context, "Object", ["$bar", "$foo"]); + assert_members_eq!(context, "Object", ["$bar", "$foo", "Class", "Module", "Object"]); } #[test] @@ -2755,6 +3139,407 @@ mod tests { assert_ancestors_eq!(context, "D", ["D", "B"]); } + #[test] + fn non_class_new_dsl_falls_back_to_constant_definition() { + let mut context = GraphTest::new(); + context.index_uri("file:///foo.rb", { + r" + class MyClass + def self.new; end + end + Foo = MyClass.new + " + }); + context.resolve(); + + assert_no_diagnostics!(&context); + + // Foo should be a constant (fallback), not a dynamic class + // MyClass.new doesn't match any handler (not Class.new or Module.new) + let foo_decl = context.graph().declarations().get(&DeclarationId::from("Foo")); + assert!(foo_decl.is_some(), "Foo declaration should exist"); + assert_eq!( + foo_decl.unwrap().kind(), + "Constant", + "Foo should be a constant, not a namespace" + ); + } + + #[test] + fn class_new_dsl_creates_inheritable_class() { + let mut context = GraphTest::new(); + context.index_uri("file:///foo.rb", { + r" + class Parent + def parent_method; end + end + Foo = Class.new(Parent) do + def foo_method; end + end + Bar = Class.new(Foo) + " + }); + context.resolve(); + + assert_no_diagnostics!(&context); + + // Parent is a regular class + assert_ancestors_eq!(context, "Parent", ["Parent", "Object"]); + assert_members_eq!(context, "Parent", vec!["parent_method()"]); + + // Foo inherits from Parent (via Class.new argument), not Object + assert_ancestors_eq!(context, "Foo", ["Foo", "Parent", "Object"]); + assert_members_eq!(context, "Foo", vec!["foo_method()"]); + + // Bar inherits from Foo + assert_ancestors_eq!(context, "Bar", ["Bar", "Foo", "Parent", "Object"]); + } + + #[test] + fn constant_inside_class_new_belongs_to_closest_lexical_scope() { + let mut context = GraphTest::new(); + context.index_uri("file:///foo.rb", { + r" + Foo = Class.new do + CONST = 1 # belongs to Object, not Foo + end + " + }); + context.resolve(); + + assert_no_diagnostics!(&context); + + assert_owner_eq!(context, "CONST", "Object"); + assert_no_members!(context, "Foo"); + } + + #[test] + fn class_new_dsl_nested_creates_scoped_class() { + let mut context = GraphTest::new(); + context.index_uri("file:///foo.rb", { + r" + Foo = Class.new do + Bar = Class.new do + def inner; end + end + attr_accessor :name + end + " + }); + context.resolve(); + + assert_no_diagnostics!(&context); + + // Foo should be a dynamic class + assert_ancestors_eq!(context, "Foo", ["Foo", "Object"]); + + // Foo should have name method from attr_accessor, and Bar as a member + // Note: attr_accessor only creates getter declarations in this codebase + assert_members_eq!(context, "Foo", vec!["Bar", "name()"]); + + // Bar should be nested under Foo as Foo::Bar + assert_ancestors_eq!(context, "Foo::Bar", ["Foo::Bar", "Object"]); + + // Foo::Bar should have inner method + assert_members_eq!(context, "Foo::Bar", vec!["inner()"]); + } + + #[test] + fn resolution_for_instance_variable_in_dsl_block() { + let mut context = GraphTest::new(); + context.index_uri("file:///foo.rb", { + r" + Foo = Class.new do + @var = 123 + end + " + }); + context.resolve(); + + assert_no_diagnostics!(&context); + + assert_instance_variables_eq!(context, "Foo::", vec!["@var"]); + } + + #[test] + fn resolution_for_class_variable_in_anonymous_class() { + let mut context = GraphTest::new(); + context.index_uri("file:///test.rb", { + r" + class Foo + Bar = Class.new do + @@cvar = 1 + end + end + " + }); + context.resolve(); + + assert_no_diagnostics!(&context); + assert_members_eq!(context, "Foo", vec!["@@cvar", "Bar"]); + } + + #[test] + fn resolution_for_constant_in_anonymous_module() { + let mut context = GraphTest::new(); + context.index_uri("file:///test.rb", { + " + Foo = Module.new do + CONST = 1 # Constants are attached to the lexical scope, so this will become a top-level constant + end + " + }); + context.resolve(); + + assert_no_diagnostics!(&context); + assert_no_members!(context, "Foo"); + assert_owner_eq!(context, "CONST", "Object"); + } + + #[test] + fn resolution_for_instance_variable_in_non_namespace_dsl() { + let mut context = GraphTest::new(); + context.index_uri("file:///foo.rb", { + r" + class MyClass + Bar.new do # this should not create a namespace + @bar = 1 + end + end + " + }); + context.resolve(); + + assert_no_diagnostics!(&context); + assert_instance_variables_eq!(context, "MyClass::", vec!["@bar"]); + } + + #[test] + fn deeply_nested_class_new_dsls() { + let mut context = GraphTest::new(); + context.index_uri("file:///foo.rb", { + r" + Foo = Class.new do + Bar = Class.new do + Baz = Class.new do + def deep_method; end + @deep_var = 1 + end + def middle_method; end + end + def outer_method; end + end + " + }); + context.resolve(); + + assert_no_diagnostics!(&context); + + assert_ancestors_eq!(context, "Foo", ["Foo", "Object"]); + assert_ancestors_eq!(context, "Foo::Bar", ["Foo::Bar", "Object"]); + assert_ancestors_eq!(context, "Foo::Bar::Baz", ["Foo::Bar::Baz", "Object"]); + + assert_members_eq!(context, "Foo", vec!["Bar", "outer_method()"]); + assert_members_eq!(context, "Foo::Bar", vec!["Baz", "middle_method()"]); + assert_members_eq!(context, "Foo::Bar::Baz", vec!["deep_method()"]); + + assert_instance_variables_eq!(context, "Foo::Bar::Baz::", vec!["@deep_var"]); + } + + #[test] + fn deeply_nested_module_new_dsls() { + let mut context = GraphTest::new(); + context.index_uri("file:///foo.rb", { + r" + Foo = Module.new do + Bar = Module.new do + Baz = Module.new do + def deep_method; end + @deep_var = 1 + end + def middle_method; end + end + def outer_method; end + end + " + }); + context.resolve(); + + assert_no_diagnostics!(&context); + + assert_ancestors_eq!(context, "Foo", ["Foo"]); + assert_ancestors_eq!(context, "Foo::Bar", ["Foo::Bar"]); + assert_ancestors_eq!(context, "Foo::Bar::Baz", ["Foo::Bar::Baz"]); + + assert_members_eq!(context, "Foo", vec!["Bar", "outer_method()"]); + assert_members_eq!(context, "Foo::Bar", vec!["Baz", "middle_method()"]); + assert_members_eq!(context, "Foo::Bar::Baz", vec!["deep_method()"]); + + assert_instance_variables_eq!(context, "Foo::Bar::Baz::", vec!["@deep_var"]); + } + + #[test] + fn anonymous_nested_class_new_does_not_leak_to_namespace() { + let mut context = GraphTest::new(); + context.index_uri("file:///foo.rb", { + r" + class Foo + Class.new do + @ivar = 123 + end + end + " + }); + context.resolve(); + + assert_no_diagnostics!(&context); + assert_declaration_does_not_exist!(context, "Foo::"); + } + + #[test] + fn class_new_dsl_with_constant_path() { + let mut context = GraphTest::new(); + context.index_uri("file:///foo.rb", { + r" + module Foo; end + + module Foo + Foo::Bar = Class.new do + def inner; end + end + end + " + }); + context.resolve(); + + assert_no_diagnostics!(&context); + + assert_ancestors_eq!(context, "Foo::Bar", ["Foo::Bar", "Object"]); + assert_members_eq!(context, "Foo::Bar", vec!["inner()"]); + } + + #[test] + fn resolution_for_nested_class_in_anonymous_class() { + let mut context = GraphTest::new(); + context.index_uri("file:///foo.rb", { + r" + class Foo + Bar = Class.new do + class Baz; end + end + end + " + }); + context.resolve(); + + assert_no_diagnostics!(&context); + + assert_ancestors_eq!(context, "Foo::Bar", ["Foo::Bar", "Object"]); + assert_ancestors_eq!(context, "Foo::Baz", ["Foo::Baz", "Object"]); + assert_members_eq!(context, "Foo", vec!["Bar", "Baz"]); + + assert_declaration_does_not_exist!(context, "Foo::Bar::Baz"); + } + + #[test] + fn module_new_dsl_with_constant_path() { + let mut context = GraphTest::new(); + context.index_uri("file:///foo.rb", { + r" + module Zip; end + + module Foo + Zip::Bar = Module.new do + def inner; end + end + end + " + }); + context.resolve(); + + assert_no_diagnostics!(&context); + assert_ancestors_eq!(context, "Zip::Bar", ["Zip::Bar"]); + assert_members_eq!(context, "Zip::Bar", vec!["inner()"]); + } + + #[test] + fn singleton_class_inside_anonymous_class() { + let mut context = GraphTest::new(); + context.index_uri("file:///foo.rb", { + r" + Foo = Class.new do + class << self + SINGLETON_CONST = 1 + def singleton_method; end + end + end + " + }); + context.resolve(); + + assert_no_diagnostics!(&context); + assert_no_members!(context, "Foo"); + assert_members_eq!(context, "Foo::", vec!["SINGLETON_CONST", "singleton_method()"]); + } + + #[test] + fn class_methods_in_anonymous_class() { + let mut context = GraphTest::new(); + context.index_uri("file:///foo.rb", { + r" + Foo = Class.new do + def self.class_method; end + def instance_method; end + end + " + }); + context.resolve(); + + assert_no_diagnostics!(&context); + assert_members_eq!(context, "Foo", vec!["instance_method()"]); + assert_members_eq!(context, "Foo::", vec!["class_method()"]); + } + + #[test] + fn nested_anonymous_class_inside_singleton_class() { + let mut context = GraphTest::new(); + context.index_uri("file:///foo.rb", { + r" + Foo = Class.new do + class << self + Bar = Class.new do + def bar_method; end + end + end + end + " + }); + context.resolve(); + + assert_no_diagnostics!(&context); + + assert_members_eq!(context, "Foo::", vec!["Bar"]); + assert_owner_eq!(context, "Foo::::Bar", "Foo::"); + assert_members_eq!(context, "Foo::::Bar", vec!["bar_method()"]); + } + + #[test] + fn alias_method_in_anonymous_class() { + let mut context = GraphTest::new(); + context.index_uri("file:///foo.rb", { + r" + Foo = Class.new do + def original; end + alias_method :aliased, :original + end + " + }); + context.resolve(); + + assert_no_diagnostics!(&context); + assert_members_eq!(context, "Foo", vec!["aliased()", "original()"]); + } + #[test] fn cyclic_include() { let mut context = GraphTest::new(); @@ -3306,7 +4091,7 @@ mod tests { assert_no_diagnostics!(&context); // Global variable aliases should still be owned by Object, regardless of where defined - assert_members_eq!(context, "Object", ["$bar", "Foo"]); + assert_members_eq!(context, "Object", ["$bar", "Class", "Foo", "Module", "Object"]); } #[test] diff --git a/rust/rubydex/src/test_utils/graph_test.rs b/rust/rubydex/src/test_utils/graph_test.rs index d9916852b..c2f76a098 100644 --- a/rust/rubydex/src/test_utils/graph_test.rs +++ b/rust/rubydex/src/test_utils/graph_test.rs @@ -23,15 +23,16 @@ impl GraphTest { } #[must_use] - fn index_source(uri: &str, source: &str) -> LocalGraph { - let mut indexer = RubyIndexer::new(uri.to_string(), source); + fn index_source(uri: &str, source: &str, dsl_method_names: Vec<&'static str>) -> LocalGraph { + let mut indexer = RubyIndexer::new(uri.to_string(), source, dsl_method_names); indexer.index(); indexer.local_graph() } pub fn index_uri(&mut self, uri: &str, source: &str) { let source = normalize_indentation(source); - let local_index = Self::index_source(uri, &source); + let dsl_method_names = self.graph.dsl_method_names(); + let local_index = Self::index_source(uri, &source, dsl_method_names); self.graph.update(local_index); } diff --git a/rust/rubydex/src/test_utils/local_graph_test.rs b/rust/rubydex/src/test_utils/local_graph_test.rs index 545361c58..4b18a9c92 100644 --- a/rust/rubydex/src/test_utils/local_graph_test.rs +++ b/rust/rubydex/src/test_utils/local_graph_test.rs @@ -2,6 +2,7 @@ use super::normalize_indentation; use crate::indexing::local_graph::LocalGraph; use crate::indexing::ruby_indexer::RubyIndexer; use crate::model::definitions::Definition; +use crate::model::graph::Graph; use crate::model::ids::UriId; use crate::offset::Offset; use crate::position::Position; @@ -18,8 +19,10 @@ impl LocalGraphTest { pub fn new(uri: &str, source: &str) -> Self { let uri = uri.to_string(); let source = normalize_indentation(source); + // Get DSL method names from a default Graph instance + let dsl_method_names = Graph::new().dsl_method_names(); - let mut indexer = RubyIndexer::new(uri.clone(), &source); + let mut indexer = RubyIndexer::new(uri.clone(), &source, dsl_method_names); indexer.index(); let graph = indexer.local_graph(); From 8533dbc8df01897e72e51a1d6fcd37a8d324b501 Mon Sep 17 00:00:00 2001 From: Stan Lo Date: Wed, 4 Feb 2026 23:06:32 +0000 Subject: [PATCH 2/4] Remove orphan dsl definitions after resolution --- rust/rubydex/src/model/graph.rs | 34 +++++++++++++++++++++++++++++++++ rust/rubydex/src/resolution.rs | 1 + 2 files changed, 35 insertions(+) diff --git a/rust/rubydex/src/model/graph.rs b/rust/rubydex/src/model/graph.rs index 0881f89e4..235d75920 100644 --- a/rust/rubydex/src/model/graph.rs +++ b/rust/rubydex/src/model/graph.rs @@ -514,6 +514,40 @@ impl Graph { } } + /// Removes orphan DSL definitions from the graph. + /// + /// DSL definitions are intermediate structures used during resolution to process + /// patterns like `Class.new` and `Module.new`. After resolution completes, + /// orphan DSL definitions (those not linked to any declaration) are no longer + /// needed and can be removed to save memory. + pub fn remove_orphan_dsl_definitions(&mut self) { + // Collect all definition IDs that are linked to declarations + let linked_ids: IdentityHashSet = self + .declarations + .values() + .flat_map(|decl| decl.definitions().iter().copied()) + .collect(); + + // Find DSL definitions that are not linked to any declaration + let orphan_dsl_ids: Vec = self + .definitions + .iter() + .filter_map(|(id, def)| { + if matches!(def, Definition::Dsl(_)) && !linked_ids.contains(id) { + Some(*id) + } else { + None + } + }) + .collect(); + + for id in orphan_dsl_ids { + if let Some(definition) = self.definitions.remove(&id) { + self.untrack_definition_strings(&definition); + } + } + } + /// Decrements the ref count for a name and removes it if the count reaches zero. /// /// This recursively untracks `parent_scope` and `nesting` names. diff --git a/rust/rubydex/src/resolution.rs b/rust/rubydex/src/resolution.rs index 692268090..a61fc711b 100644 --- a/rust/rubydex/src/resolution.rs +++ b/rust/rubydex/src/resolution.rs @@ -136,6 +136,7 @@ impl<'a> Resolver<'a> { } self.handle_remaining_definitions(other_ids); + self.graph.remove_orphan_dsl_definitions(); } fn initialize_builtin_declarations(&mut self) { From b91d5bbfdc20f47c4fe98f23a0dbdb196c5bd4f6 Mon Sep 17 00:00:00 2001 From: Stan Lo Date: Thu, 5 Feb 2026 12:24:14 +0000 Subject: [PATCH 3/4] WIP: new tests --- rust/rubydex/src/resolution.rs | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/rust/rubydex/src/resolution.rs b/rust/rubydex/src/resolution.rs index a61fc711b..eb0a96f8f 100644 --- a/rust/rubydex/src/resolution.rs +++ b/rust/rubydex/src/resolution.rs @@ -3155,15 +3155,8 @@ mod tests { assert_no_diagnostics!(&context); - // Foo should be a constant (fallback), not a dynamic class - // MyClass.new doesn't match any handler (not Class.new or Module.new) let foo_decl = context.graph().declarations().get(&DeclarationId::from("Foo")); - assert!(foo_decl.is_some(), "Foo declaration should exist"); - assert_eq!( - foo_decl.unwrap().kind(), - "Constant", - "Foo should be a constant, not a namespace" - ); + assert!(matches!(foo_decl, Some(Declaration::Constant(_)))); } #[test] @@ -3315,6 +3308,28 @@ mod tests { assert_instance_variables_eq!(context, "MyClass::", vec!["@bar"]); } + #[test] + fn dsl_without_receiver_does_not_capture_constant() { + // Simulates T::Enum pattern: `Value = new("value")` inside a class + // When `new` has no receiver, it shouldn't be treated as a namespace-creating DSL. + // The constant should be assigned to the enclosing class. + let mut context = GraphTest::new(); + context.index_uri("file:///foo.rb", { + r" + class MyEnum + Value1 = new('value1') + Value2 = new('value2') + end + " + }); + context.resolve(); + + assert_no_diagnostics!(&context); + assert_declaration_exists!(context, "MyEnum::Value1"); + assert_declaration_exists!(context, "MyEnum::Value2"); + assert_members_eq!(context, "MyEnum", vec!["Value1", "Value2"]); + } + #[test] fn deeply_nested_class_new_dsls() { let mut context = GraphTest::new(); From 1959dc2dee2f72a53551013013ad570345ab9949 Mon Sep 17 00:00:00 2001 From: Stan Lo Date: Thu, 5 Feb 2026 12:52:00 +0000 Subject: [PATCH 4/4] Fix DSL's handling on T::Enum like patterns --- rust/rubydex/src/model/dsl_processors.rs | 70 ++++++++++++++++++++++++ rust/rubydex/src/resolution.rs | 38 +++++++------ 2 files changed, 92 insertions(+), 16 deletions(-) diff --git a/rust/rubydex/src/model/dsl_processors.rs b/rust/rubydex/src/model/dsl_processors.rs index 1c159e18e..c0790e266 100644 --- a/rust/rubydex/src/model/dsl_processors.rs +++ b/rust/rubydex/src/model/dsl_processors.rs @@ -44,6 +44,76 @@ pub fn module_new_matches(receiver_decl_id: Option) -> bool { receiver_decl_id == Some(*MODULE_ID) } +/// Result of attempting to match a DSL against processors. +pub enum DslMatchResult { + /// A processor matched - use it to handle the DSL + Matched(DslProcessor), + /// No processor matched, but one might match after more resolution + /// (e.g., receiver exists but not resolved yet) + Retry, + /// No processor will ever match this DSL + /// (e.g., no receiver exists, or receiver resolved but doesn't match any processor) + WillNotMatch, +} + +/// Finds a matching processor for the given DSL, with detailed match result. +/// +/// Parameters: +/// - `processors`: The available DSL processors +/// - `method_name`: The method name being called +/// - `receiver_name`: Whether a receiver exists in source (Some = exists, None = no receiver) +/// - `receiver_decl_id`: The resolved receiver declaration (if resolved) +/// +/// Returns: +/// - `Matched(processor)` if a processor matches +/// - `Retry` if receiver exists but not resolved, and method could match a processor +/// - `WillNotMatch` if no processor will ever match +#[must_use] +pub fn find_matching_processor( + processors: &[DslProcessor], + method_name: &str, + receiver_name: Option, + receiver_decl_id: Option, +) -> DslMatchResult { + let mut could_match_later = false; + + for processor in processors { + // Method name must match + if processor.method_name != method_name { + continue; + } + + // Method matches - check receiver + match (receiver_name, receiver_decl_id) { + // Receiver resolved - check if it matches + (Some(_), Some(decl_id)) => { + if (processor.matches)(Some(decl_id)) { + return DslMatchResult::Matched(*processor); + } + // Receiver resolved but doesn't match this processor - try next + } + // Receiver exists but not resolved yet - could match later + (Some(_), None) => { + could_match_later = true; + } + // No receiver in source - can never match processors that need a receiver + (None, _) => { + // Check if this processor matches with no receiver + if (processor.matches)(None) { + return DslMatchResult::Matched(*processor); + } + // This processor requires a receiver - try next + } + } + } + + if could_match_later { + DslMatchResult::Retry + } else { + DslMatchResult::WillNotMatch + } +} + /// Common data extracted from a DSL definition and its associated constant. struct DslContext { uri_id: UriId, diff --git a/rust/rubydex/src/resolution.rs b/rust/rubydex/src/resolution.rs index eb0a96f8f..e733fea25 100644 --- a/rust/rubydex/src/resolution.rs +++ b/rust/rubydex/src/resolution.rs @@ -10,7 +10,7 @@ use crate::model::{ Namespace, SingletonClassDeclaration, }, definitions::{Definition, DynamicClassDefinition, DynamicModuleDefinition, Mixin, Receiver}, - dsl_processors::is_parent_dsl_processed, + dsl_processors::{find_matching_processor, is_parent_dsl_processed, DslMatchResult}, graph::{CLASS_ID, Graph, MODULE_ID, OBJECT_ID}, identity_maps::{IdentityHashMap, IdentityHashSet}, ids::{DeclarationId, DefinitionId, NameId, ReferenceId, StringId}, @@ -1862,28 +1862,34 @@ impl<'a> Resolver<'a> { .map(|s| s.as_str().to_string()) .unwrap_or_default(); - let matching_processor = self - .graph - .dsl_processors() - .iter() - .find(|p| p.method_name == method_name && (p.matches)(receiver_decl_id)) - .copied(); + let match_result = find_matching_processor( + self.graph.dsl_processors(), + &method_name, + receiver_name, + receiver_decl_id, + ); - match matching_processor { - Some(processor) => { + match match_result { + DslMatchResult::Matched(processor) => { let result = (processor.handle)(self.graph, dsl_def_id); if result.is_some() { self.made_progress = true; } result } - None => { - if receiver_decl_id.is_some() { - assigned_to - } else { - self.unit_queue.push_back(Unit::Dsl(dsl_def_id)); - None - } + DslMatchResult::Retry => { + self.unit_queue.push_back(Unit::Dsl(dsl_def_id)); + None + } + DslMatchResult::WillNotMatch => { + // Only return assigned_to if it points to a constant definition. + // If it points to something else (like a DSL), we can't process it here. + assigned_to.filter(|id| { + matches!( + self.graph.definitions().get(id), + Some(Definition::Constant(_) | Definition::ConstantAlias(_)) + ) + }) } } }