diff --git a/rust/rubydex-mcp/src/server.rs b/rust/rubydex-mcp/src/server.rs index 5a0194c8f..64cd76fa8 100644 --- a/rust/rubydex-mcp/src/server.rs +++ b/rust/rubydex-mcp/src/server.rs @@ -189,7 +189,7 @@ fn format_ancestors(graph: &Graph, ancestors: &Ancestors) -> Vec { @@ -227,7 +227,7 @@ impl RubydexServer { }; if let Some(kind) = kind_filter - && !decl.kind().eq_ignore_ascii_case(kind) + && !decl.kind().as_api_str().eq_ignore_ascii_case(kind) { continue; } @@ -248,7 +248,7 @@ impl RubydexServer { results.push(serde_json::json!({ "name": decl.name(), - "kind": decl.kind(), + "kind": decl.kind().as_api_str(), "locations": locations, })); } @@ -310,7 +310,7 @@ impl RubydexServer { let mut member = serde_json::json!({ "name": member_decl.name(), - "kind": member_decl.kind(), + "kind": member_decl.kind().as_api_str(), }); if let Some(def) = member_def @@ -331,7 +331,7 @@ impl RubydexServer { let result = serde_json::json!({ "name": decl.name(), - "kind": decl.kind(), + "kind": decl.kind().as_api_str(), "definitions": definitions, "ancestors": ancestors, "members": members, @@ -356,7 +356,7 @@ impl RubydexServer { let desc_decl = graph.declarations().get(desc_id)?; Some(serde_json::json!({ "name": desc_decl.name(), - "kind": desc_decl.kind(), + "kind": desc_decl.kind().as_api_str(), })) }) .collect(); @@ -456,7 +456,7 @@ impl RubydexServer { let decl_name = graph .definition_id_to_declaration_id(*def_id) .and_then(|decl_id| graph.declarations().get(decl_id)) - .map(|decl| (decl.name().to_string(), decl.kind())); + .map(|decl| (decl.name().to_string(), decl.kind().as_api_str())); if let Some((name, kind)) = decl_name { declarations.push(serde_json::json!({ @@ -482,14 +482,14 @@ impl RubydexServer { let state = ensure_graph_ready!(self); let graph = state.graph.as_ref().unwrap(); - let mut breakdown: HashMap<&str, usize> = HashMap::new(); + let mut breakdown: HashMap<&'static str, usize> = HashMap::new(); for decl in graph.declarations().values() { - *breakdown.entry(decl.kind()).or_default() += 1; + *breakdown.entry(decl.kind().as_api_str()).or_default() += 1; } let breakdown_json: serde_json::Value = breakdown .iter() - .map(|(k, v)| (k.to_string(), serde_json::json!(v))) + .map(|(k, v)| ((*k).to_string(), serde_json::json!(v))) .collect(); let result = serde_json::json!({ diff --git a/rust/rubydex/src/diagnostic.rs b/rust/rubydex/src/diagnostic.rs index bdd3fe8c8..b5ab412bf 100644 --- a/rust/rubydex/src/diagnostic.rs +++ b/rust/rubydex/src/diagnostic.rs @@ -105,4 +105,10 @@ rules! { TopLevelMixinSelf; // Resolution + KindRedefinition; + ParentRedefinition; + NonClassSuperclass; + CircularDependency; + NonModuleMixin; + UnresolvedConstantReference; } diff --git a/rust/rubydex/src/model/declaration.rs b/rust/rubydex/src/model/declaration.rs index 9bd99c9b8..1c24a7569 100644 --- a/rust/rubydex/src/model/declaration.rs +++ b/rust/rubydex/src/model/declaration.rs @@ -1,6 +1,7 @@ use crate::assert_mem_size; use crate::diagnostic::Diagnostic; use crate::model::{ + definitions::DefinitionKind, identity_maps::{IdentityHashMap, IdentityHashSet}, ids::{DeclarationId, DefinitionId, NameId, ReferenceId, StringId}, }; @@ -54,6 +55,75 @@ impl<'a> IntoIterator for &'a Ancestors { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DeclarationKind { + Class, + SingletonClass, + Module, + Todo, + Constant, + ConstantAlias, + Method, + GlobalVariable, + InstanceVariable, + ClassVariable, +} + +impl DeclarationKind { + /// Returns the canonical `PascalCase` name used in external APIs and serialization. + #[must_use] + pub fn as_api_str(self) -> &'static str { + match self { + DeclarationKind::Class => "Class", + DeclarationKind::SingletonClass => "SingletonClass", + DeclarationKind::Module => "Module", + DeclarationKind::Constant => "Constant", + DeclarationKind::ConstantAlias => "ConstantAlias", + DeclarationKind::Method => "Method", + DeclarationKind::GlobalVariable => "GlobalVariable", + DeclarationKind::InstanceVariable => "InstanceVariable", + DeclarationKind::ClassVariable => "ClassVariable", + DeclarationKind::Todo => "", + } + } + + #[must_use] + pub fn from_definition_kind(definition_kind: DefinitionKind) -> Self { + match definition_kind { + DefinitionKind::Class => DeclarationKind::Class, + DefinitionKind::SingletonClass => DeclarationKind::SingletonClass, + DefinitionKind::Module => DeclarationKind::Module, + DefinitionKind::Constant => DeclarationKind::Constant, + DefinitionKind::ConstantAlias => DeclarationKind::ConstantAlias, + DefinitionKind::Method + | DefinitionKind::MethodAlias + | DefinitionKind::AttrAccessor + | DefinitionKind::AttrReader + | DefinitionKind::AttrWriter => DeclarationKind::Method, + DefinitionKind::InstanceVariable => DeclarationKind::InstanceVariable, + DefinitionKind::ClassVariable => DeclarationKind::ClassVariable, + DefinitionKind::GlobalVariable | DefinitionKind::GlobalVariableAlias => DeclarationKind::GlobalVariable, + } + } +} + +impl std::fmt::Display for DeclarationKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DeclarationKind::Class => write!(f, "class"), + DeclarationKind::SingletonClass => write!(f, "singleton class"), + DeclarationKind::Module => write!(f, "module"), + DeclarationKind::Todo => write!(f, ""), + DeclarationKind::Constant => write!(f, "constant"), + DeclarationKind::ConstantAlias => write!(f, "constant alias"), + DeclarationKind::Method => write!(f, "method"), + DeclarationKind::GlobalVariable => write!(f, "global variable"), + DeclarationKind::InstanceVariable => write!(f, "instance variable"), + DeclarationKind::ClassVariable => write!(f, "class variable"), + } + } +} + macro_rules! all_declarations { ($value:expr, $var:ident => $expr:expr) => { match $value { @@ -264,15 +334,15 @@ impl Declaration { } #[must_use] - pub fn kind(&self) -> &'static str { + pub fn kind(&self) -> DeclarationKind { match self { Declaration::Namespace(namespace) => namespace.kind(), - Declaration::Constant(_) => "Constant", - Declaration::ConstantAlias(_) => "ConstantAlias", - Declaration::Method(_) => "Method", - Declaration::GlobalVariable(_) => "GlobalVariable", - Declaration::InstanceVariable(_) => "InstanceVariable", - Declaration::ClassVariable(_) => "ClassVariable", + Declaration::Constant(_) => DeclarationKind::Constant, + Declaration::ConstantAlias(_) => DeclarationKind::ConstantAlias, + Declaration::Method(_) => DeclarationKind::Method, + Declaration::GlobalVariable(_) => DeclarationKind::GlobalVariable, + Declaration::InstanceVariable(_) => DeclarationKind::InstanceVariable, + Declaration::ClassVariable(_) => DeclarationKind::ClassVariable, } } @@ -362,12 +432,20 @@ impl Declaration { all_declarations!(self, it => &it.diagnostics) } + pub fn diagnostics_mut(&mut self) -> &mut Vec { + all_declarations!(self, it => &mut it.diagnostics) + } + pub fn take_diagnostics(&mut self) -> Vec { all_declarations!(self, it => std::mem::take(&mut it.diagnostics)) } pub fn add_diagnostic(&mut self, diagnostic: Diagnostic) { - all_declarations!(self, it => it.diagnostics.push(diagnostic)); + all_declarations!(self, it => { + if !it.diagnostics.iter().any(|d| d.rule() == diagnostic.rule() && d.uri_id() == diagnostic.uri_id() && d.offset() == diagnostic.offset()) { + it.diagnostics.push(diagnostic); + } + }); } pub fn clear_diagnostics(&mut self) { @@ -386,12 +464,12 @@ assert_mem_size!(Namespace, 16); impl Namespace { #[must_use] - pub fn kind(&self) -> &'static str { + pub fn kind(&self) -> DeclarationKind { match self { - Namespace::Class(_) => "Class", - Namespace::SingletonClass(_) => "SingletonClass", - Namespace::Module(_) => "Module", - Namespace::Todo(_) => "", + Namespace::Class(_) => DeclarationKind::Class, + Namespace::SingletonClass(_) => DeclarationKind::SingletonClass, + Namespace::Module(_) => DeclarationKind::Module, + Namespace::Todo(_) => DeclarationKind::Todo, } } diff --git a/rust/rubydex/src/model/definitions.rs b/rust/rubydex/src/model/definitions.rs index 2a8fdaaed..ae33f2c26 100644 --- a/rust/rubydex/src/model/definitions.rs +++ b/rust/rubydex/src/model/definitions.rs @@ -55,6 +55,45 @@ impl DefinitionFlags { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum DefinitionKind { + Class, + SingletonClass, + Module, + Constant, + ConstantAlias, + GlobalVariable, + InstanceVariable, + ClassVariable, + AttrAccessor, + AttrReader, + AttrWriter, + Method, + MethodAlias, + GlobalVariableAlias, +} + +impl std::fmt::Display for DefinitionKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DefinitionKind::Class => write!(f, "class"), + DefinitionKind::SingletonClass => write!(f, "singleton class"), + DefinitionKind::Module => write!(f, "module"), + DefinitionKind::Constant => write!(f, "constant"), + DefinitionKind::ConstantAlias => write!(f, "constant alias"), + DefinitionKind::Method => write!(f, "method"), + DefinitionKind::AttrAccessor => write!(f, "attr_accessor"), + DefinitionKind::AttrReader => write!(f, "attr_reader"), + DefinitionKind::AttrWriter => write!(f, "attr_writer"), + DefinitionKind::GlobalVariable => write!(f, "global variable"), + DefinitionKind::InstanceVariable => write!(f, "instance variable"), + DefinitionKind::ClassVariable => write!(f, "class variable"), + DefinitionKind::MethodAlias => write!(f, "method alias"), + DefinitionKind::GlobalVariableAlias => write!(f, "global variable alias"), + } + } +} + #[derive(Debug)] pub enum Definition { Class(Box), @@ -122,22 +161,22 @@ impl Definition { } #[must_use] - pub fn kind(&self) -> &'static str { + pub fn kind(&self) -> DefinitionKind { match self { - Definition::Class(_) => "Class", - Definition::SingletonClass(_) => "SingletonClass", - Definition::Module(_) => "Module", - Definition::Constant(_) => "Constant", - Definition::ConstantAlias(_) => "ConstantAlias", - Definition::Method(_) => "Method", - Definition::AttrAccessor(_) => "AttrAccessor", - Definition::AttrReader(_) => "AttrReader", - Definition::AttrWriter(_) => "AttrWriter", - Definition::GlobalVariable(_) => "GlobalVariable", - Definition::InstanceVariable(_) => "InstanceVariable", - Definition::ClassVariable(_) => "ClassVariable", - Definition::MethodAlias(_) => "AliasMethod", - Definition::GlobalVariableAlias(_) => "GlobalVariableAlias", + Definition::Class(_) => DefinitionKind::Class, + Definition::SingletonClass(_) => DefinitionKind::SingletonClass, + Definition::Module(_) => DefinitionKind::Module, + Definition::Constant(_) => DefinitionKind::Constant, + Definition::ConstantAlias(_) => DefinitionKind::ConstantAlias, + Definition::Method(_) => DefinitionKind::Method, + Definition::AttrAccessor(_) => DefinitionKind::AttrAccessor, + Definition::AttrReader(_) => DefinitionKind::AttrReader, + Definition::AttrWriter(_) => DefinitionKind::AttrWriter, + Definition::GlobalVariable(_) => DefinitionKind::GlobalVariable, + Definition::InstanceVariable(_) => DefinitionKind::InstanceVariable, + Definition::ClassVariable(_) => DefinitionKind::ClassVariable, + Definition::MethodAlias(_) => DefinitionKind::MethodAlias, + Definition::GlobalVariableAlias(_) => DefinitionKind::GlobalVariableAlias, } } diff --git a/rust/rubydex/src/model/graph.rs b/rust/rubydex/src/model/graph.rs index 3436ec729..ec5e15f36 100644 --- a/rust/rubydex/src/model/graph.rs +++ b/rust/rubydex/src/model/graph.rs @@ -1,10 +1,10 @@ use std::collections::hash_map::Entry; use std::sync::LazyLock; -use crate::diagnostic::Diagnostic; +use crate::diagnostic::{Diagnostic, Rule}; use crate::indexing::local_graph::LocalGraph; -use crate::model::declaration::{Ancestor, Declaration, Namespace}; -use crate::model::definitions::Definition; +use crate::model::declaration::{Ancestor, Declaration, DeclarationKind, Namespace}; +use crate::model::definitions::{Definition, DefinitionKind}; use crate::model::document::Document; use crate::model::encoding::Encoding; use crate::model::identity_maps::{IdentityHashMap, IdentityHashSet}; @@ -72,7 +72,7 @@ impl Graph { /// # Panics /// - /// Will panic if the `definition_id` is not registered in the graph + /// Panics if `definition_id` is not present in the definitions map. pub fn add_declaration( &mut self, definition_id: DefinitionId, @@ -113,7 +113,37 @@ impl Graph { new_declaration.add_definition(definition_id); self.declarations.insert(declaration_id, new_declaration); } else { - occupied_entry.get_mut().add_definition(definition_id); + let declaration = occupied_entry.get_mut(); + let definition = self.definitions.get(&definition_id).unwrap(); + let definition_declaration_kind = DeclarationKind::from_definition_kind(definition.kind()); + // Only emit when the existing declaration is a concrete namespace and the new definition + // is an incompatible kind: a different namespace, or a non-promotable constant. + // We skip constants/aliases becoming namespaces and promotable constants (dynamic values + // that might represent a class). + let is_namespace = matches!(declaration.kind(), DeclarationKind::Class | DeclarationKind::Module); + let is_mismatch = definition_declaration_kind != declaration.kind(); + let is_non_promotable_constant = + matches!(definition, Definition::Constant(c) if !c.flags().is_promotable()); + let should_emit = is_namespace + && is_mismatch + && (matches!( + definition_declaration_kind, + DeclarationKind::Class | DeclarationKind::Module + ) || is_non_promotable_constant); + if should_emit { + declaration.add_diagnostic(Diagnostic::new( + Rule::KindRedefinition, + *definition.uri_id(), + definition.offset().clone(), + format!( + "Redefining `{}` as `{}`, previously defined as `{}`", + declaration.name(), + definition_declaration_kind, + declaration.kind() + ), + )); + } + declaration.add_definition(definition_id); } } Entry::Vacant(vacant_entry) => { @@ -230,6 +260,11 @@ impl Graph { &self.documents } + #[must_use] + pub fn documents_mut(&mut self) -> &mut IdentityHashMap { + &mut self.documents + } + /// # Panics /// /// Panics if the definition is not found @@ -341,12 +376,19 @@ impl Graph { &self.constant_references } + #[must_use] + pub fn constant_references_mut(&mut self) -> &mut IdentityHashMap { + &mut self.constant_references + } + // Returns an immutable reference to the method references map #[must_use] pub fn method_references(&self) -> &IdentityHashMap { &self.method_references } + // Diagnostics + #[must_use] pub fn all_diagnostics(&self) -> Vec<&Diagnostic> { let document_diagnostics = self.documents.values().flat_map(Document::all_diagnostics); @@ -896,8 +938,8 @@ impl Graph { let mut declarations_with_docs = 0; let mut total_doc_size = 0; let mut multi_definition_count = 0; - let mut declarations_types: HashMap<&str, usize> = HashMap::new(); - let mut linked_definition_types: HashMap<&str, usize> = HashMap::new(); + let mut declarations_types: HashMap = HashMap::new(); + let mut linked_definition_types: HashMap = HashMap::new(); let mut linked_definition_ids: HashSet<&DefinitionId> = HashSet::new(); for declaration in self.declarations.values() { @@ -928,7 +970,7 @@ impl Graph { } // Count ALL definitions by type (including unlinked) - let mut all_definition_types: HashMap<&str, usize> = HashMap::new(); + let mut all_definition_types: HashMap = HashMap::new(); for def in self.definitions.values() { *all_definition_types.entry(def.kind()).or_insert(0) += 1; } @@ -959,7 +1001,7 @@ impl Graph { let mut types: Vec<_> = declarations_types.iter().collect(); types.sort_by_key(|(_, count)| std::cmp::Reverse(**count)); for (kind, count) in types { - println!(" {kind:20} {count:6}"); + println!(" {kind:20} {count:6}", kind = kind.to_string()); } // Combined definition breakdown: total, linked, orphan diff --git a/rust/rubydex/src/resolution.rs b/rust/rubydex/src/resolution.rs index 2c914f41f..39d3a685b 100644 --- a/rust/rubydex/src/resolution.rs +++ b/rust/rubydex/src/resolution.rs @@ -1,19 +1,23 @@ use std::{ collections::{HashSet, VecDeque}, hash::BuildHasher, + ops::Deref, }; -use crate::model::{ - declaration::{ - Ancestor, Ancestors, ClassDeclaration, ClassVariableDeclaration, ConstantAliasDeclaration, ConstantDeclaration, - Declaration, GlobalVariableDeclaration, InstanceVariableDeclaration, MethodDeclaration, ModuleDeclaration, - Namespace, SingletonClassDeclaration, TodoDeclaration, +use crate::{ + diagnostic::{Diagnostic, Rule}, + model::{ + declaration::{ + Ancestor, Ancestors, ClassDeclaration, ClassVariableDeclaration, ConstantAliasDeclaration, + ConstantDeclaration, Declaration, DeclarationKind, GlobalVariableDeclaration, InstanceVariableDeclaration, + MethodDeclaration, ModuleDeclaration, Namespace, SingletonClassDeclaration, TodoDeclaration, + }, + definitions::{Definition, Mixin, Receiver}, + graph::{CLASS_ID, Graph, MODULE_ID, OBJECT_ID}, + identity_maps::{IdentityHashMap, IdentityHashSet}, + ids::{DeclarationId, DefinitionId, NameId, ReferenceId, StringId}, + name::{Name, NameRef, ParentScope}, }, - definitions::{Definition, Mixin, Receiver}, - graph::{CLASS_ID, Graph, MODULE_ID, OBJECT_ID}, - identity_maps::{IdentityHashMap, IdentityHashSet}, - ids::{DeclarationId, DefinitionId, NameId, ReferenceId, StringId}, - name::{Name, NameRef, ParentScope}, }; pub enum Unit { @@ -155,6 +159,38 @@ impl<'a> Resolver<'a> { } self.handle_remaining_definitions(other_ids); + + for left_unit in self.unit_queue.drain(..) { + if let Unit::ConstantRef(id) = left_unit { + let reference = self.graph.constant_references().get(&id).unwrap(); + let uri_id = reference.uri_id(); + let offset = reference.offset().clone(); + let name = self + .graph + .strings() + .get(self.graph.names().get(reference.name_id()).unwrap().str()) + .unwrap() + .deref() + .clone(); + + // Skip synthetic singleton class names (e.g., ``). These are auto-generated + // for method call dispatch and would produce confusing diagnostics. + if name.starts_with('<') { + continue; + } + + self.graph + .documents_mut() + .get_mut(&uri_id) + .unwrap() + .add_diagnostic(Diagnostic::new( + Rule::UnresolvedConstantReference, + uri_id, + offset, + format!("Unresolved constant reference: `{name}`"), + )); + } + } } /// Resolves a single constant against the graph. This method is not meant to be used by the resolution phase, but by @@ -239,7 +275,8 @@ impl<'a> Resolver<'a> { 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 will be picked up by the drain loop + 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); @@ -686,6 +723,7 @@ impl<'a> Resolver<'a> { /// /// Can panic if there's inconsistent data in the graph #[must_use] + #[allow(clippy::too_many_lines)] fn linearize_ancestors(&mut self, declaration_id: DeclarationId, context: &mut LinearizationContext) -> Ancestors { { let declaration = self.graph.declarations_mut().get_mut(&declaration_id).unwrap(); @@ -741,28 +779,44 @@ impl<'a> Resolver<'a> { if let Declaration::Namespace(Namespace::SingletonClass(_)) = declaration { let attached_decl = self.graph.declarations().get(declaration.owner_id()).unwrap(); + let attached_mixins = attached_decl + .definitions() + .iter() + .filter_map(|definition_id| { + self.mixins_of(*definition_id) + .map(|mixins| mixins.into_iter().map(|mixin| (mixin, *definition_id))) + }) + .flatten() + .collect::>(); + mixins.extend( - attached_decl - .definitions() - .iter() - .filter_map(|definition_id| self.mixins_of(*definition_id)) - .flatten() + attached_mixins + .into_iter() + .map(|(mixin, _)| mixin) .filter(|mixin| matches!(mixin, Mixin::Extend(_))), ); } // Consider only prepends and includes for the current declaration + let decl_mixins = declaration + .definitions() + .iter() + .filter_map(|definition_id| { + self.mixins_of(*definition_id) + .map(|mixins| mixins.into_iter().map(|mixin| (mixin, *definition_id))) + }) + .flatten() + .collect::>(); + mixins.extend( - declaration - .definitions() - .iter() - .filter_map(|definition_id| self.mixins_of(*definition_id)) - .flatten() + decl_mixins + .into_iter() + .map(|(mixin, _)| mixin) .filter(|mixin| matches!(mixin, Mixin::Prepend(_) | Mixin::Include(_))), ); let (linearized_prepends, linearized_includes) = - self.linearize_mixins(context, mixins, parent_ancestors.as_ref()); + self.linearize_mixins(declaration_id, context, &mixins, parent_ancestors.as_ref()); // Build the final list let mut ancestors = Vec::new(); @@ -807,17 +861,19 @@ impl<'a> Resolver<'a> { Declaration::Namespace(Namespace::Class(_)) => { let definition_ids = declaration.definitions().to_vec(); - Some(match self.linearize_parent_class(&definition_ids, context) { - Ancestors::Complete(ids) => ids, - Ancestors::Cyclic(ids) => { - context.cyclic = true; - ids - } - Ancestors::Partial(ids) => { - context.partial = true; - ids - } - }) + Some( + match self.linearize_parent_class(declaration_id, &definition_ids, context) { + Ancestors::Complete(ids) => ids, + Ancestors::Cyclic(ids) => { + context.cyclic = true; + ids + } + Ancestors::Partial(ids) => { + context.partial = true; + ids + } + }, + ) } Declaration::Namespace(Namespace::SingletonClass(_)) => { let owner_id = *declaration.owner_id(); @@ -845,14 +901,17 @@ impl<'a> Resolver<'a> { /// Linearize all mixins into a prepend and include list. This function requires the parent ancestors because included /// modules are deduplicated against them + #[allow(clippy::too_many_lines)] fn linearize_mixins( &mut self, + declaration_id: DeclarationId, context: &mut LinearizationContext, - mixins: Vec, + mixins: &Vec, parent_ancestors: Option<&Vec>, ) -> (VecDeque, VecDeque) { let mut linearized_prepends = VecDeque::new(); let mut linearized_includes = VecDeque::new(); + let mut diagnostics = Vec::new(); // IMPORTANT! In the slice of mixins we receive, extends are the ones that occurred in the attached object, which we // collect ahead of time. This is the reason why we apparently treat an extend like an include, because an extend in @@ -868,6 +927,26 @@ impl<'a> Resolver<'a> { Mixin::Prepend(_) => { match self.graph.names().get(constant_reference.name_id()).unwrap() { NameRef::Resolved(resolved) => { + let mixin_declaration = self.graph.declarations().get(resolved.declaration_id()).unwrap(); + + let is_alias_or_promotable = matches!( + mixin_declaration.kind(), + DeclarationKind::ConstantAlias | DeclarationKind::Todo + ) || (mixin_declaration.kind() == DeclarationKind::Constant + && self.graph.all_definitions_promotable(mixin_declaration)); + + if !is_alias_or_promotable && mixin_declaration.kind() != DeclarationKind::Module { + diagnostics.push(Diagnostic::new( + Rule::NonModuleMixin, + constant_reference.uri_id(), + constant_reference.offset().clone(), + format!( + "Mixin `{}` is not a module", + self.graph.strings().get(resolved.name().str()).unwrap().deref() + ), + )); + } + let Some(module_id) = self.resolve_to_namespace(*resolved.declaration_id()) else { continue; }; @@ -907,6 +986,26 @@ impl<'a> Resolver<'a> { Mixin::Include(_) | Mixin::Extend(_) => { match self.graph.names().get(constant_reference.name_id()).unwrap() { NameRef::Resolved(resolved) => { + let mixin_declaration = self.graph.declarations().get(resolved.declaration_id()).unwrap(); + + let is_alias_or_promotable = matches!( + mixin_declaration.kind(), + DeclarationKind::ConstantAlias | DeclarationKind::Todo + ) || (mixin_declaration.kind() == DeclarationKind::Constant + && self.graph.all_definitions_promotable(mixin_declaration)); + + if !is_alias_or_promotable && mixin_declaration.kind() != DeclarationKind::Module { + diagnostics.push(Diagnostic::new( + Rule::NonModuleMixin, + constant_reference.uri_id(), + constant_reference.offset().clone(), + format!( + "Mixin `{}` is not a module", + self.graph.strings().get(resolved.name().str()).unwrap().deref() + ), + )); + } + let Some(module_id) = self.resolve_to_namespace(*resolved.declaration_id()) else { continue; }; @@ -947,6 +1046,46 @@ impl<'a> Resolver<'a> { } } + let existing = self + .graph + .declarations_mut() + .get_mut(&declaration_id) + .unwrap() + .diagnostics_mut(); + for diagnostic in diagnostics { + if !existing.iter().any(|d| { + d.rule() == diagnostic.rule() && d.uri_id() == diagnostic.uri_id() && d.offset() == diagnostic.offset() + }) { + existing.push(diagnostic); + } + } + + if context.cyclic { + for mixin in mixins { + let constant_reference = self + .graph + .constant_references() + .get(mixin.constant_reference_id()) + .unwrap(); + + let diagnostic = Diagnostic::new( + Rule::CircularDependency, + constant_reference.uri_id(), + constant_reference.offset().clone(), + format!( + "Circular dependency: `{}` is parent of itself", + self.graph.declarations().get(&declaration_id).unwrap().name() + ), + ); + + self.graph + .declarations_mut() + .get_mut(&declaration_id) + .unwrap() + .add_diagnostic(diagnostic); + } + } + (linearized_prepends, linearized_includes) } @@ -1647,7 +1786,7 @@ impl<'a> Resolver<'a> { // For classes (the regular case), we need to return the singleton class of its parent let definition_ids = decl.definitions().to_vec(); - let (picked_parent, partial) = self.get_parent_class(&definition_ids); + let (picked_parent, _parent_info, partial) = self.get_parent_class(attached_id, &definition_ids); ( self.get_or_create_singleton_class(picked_parent) .expect("parent class should always be a namespace"), @@ -1662,7 +1801,11 @@ impl<'a> Resolver<'a> { } } - fn get_parent_class(&self, definition_ids: &[DefinitionId]) -> (DeclarationId, bool) { + fn get_parent_class( + &mut self, + declaration_id: DeclarationId, + definition_ids: &[DefinitionId], + ) -> (DeclarationId, Option<(ReferenceId, DefinitionId)>, bool) { let mut explicit_parents = Vec::new(); let mut partial = false; @@ -1680,9 +1823,7 @@ impl<'a> Resolver<'a> { match name { NameRef::Resolved(resolved) => { - if let Some(parent_id) = self.resolve_to_namespace(*resolved.declaration_id()) { - explicit_parents.push(parent_id); - } + explicit_parents.push((*resolved.declaration_id(), *superclass, *definition_id)); } NameRef::Unresolved(_) => { partial = true; @@ -1691,18 +1832,118 @@ impl<'a> Resolver<'a> { } } - // If there's more than one parent class that isn't `Object` and they are different, then there's a superclass - // mismatch error. TODO: We should add a diagnostic here - (explicit_parents.first().copied().unwrap_or(*OBJECT_ID), partial) + // Dedup explicit parents that resolve to the same declaration + explicit_parents.dedup_by(|(name_a, _, _), (name_b, _, _)| name_a == name_b); + + for (parent_declaration_id, superclass_ref_id, definition_id) in &explicit_parents { + let parent_declaration = self.graph.declarations().get(parent_declaration_id).unwrap(); + + // Skip aliases (might resolve to a class) and promotable constants (might be a class at runtime) + let is_alias_or_promotable = matches!( + parent_declaration.kind(), + DeclarationKind::ConstantAlias | DeclarationKind::Todo + ) || (parent_declaration.kind() == DeclarationKind::Constant + && self.graph.all_definitions_promotable(parent_declaration)); + + if !is_alias_or_promotable && parent_declaration.kind() != DeclarationKind::Class { + let diagnostic = Diagnostic::new( + Rule::NonClassSuperclass, + *self.graph.definitions().get(definition_id).unwrap().uri_id(), + self.graph + .constant_references() + .get(superclass_ref_id) + .unwrap() + .offset() + .clone(), + format!( + "Superclass `{}` of `{}` is not a class (found `{}`)", + parent_declaration.name(), + self.graph.declarations().get(&declaration_id).unwrap().name(), + parent_declaration.kind() + ), + ); + + self.graph + .declarations_mut() + .get_mut(&declaration_id) + .unwrap() + .add_diagnostic(diagnostic); + } + } + + if explicit_parents.len() > 1 { + let (first_parent_id, _first_superclass_ref_id, _first_definition_id) = explicit_parents[0]; + + for (parent_declaration_id, superclass_ref_id, definition_id) in explicit_parents.iter().skip(1) { + let diagnostic = Diagnostic::new( + Rule::ParentRedefinition, + *self.graph.definitions().get(definition_id).unwrap().uri_id(), + self.graph + .constant_references() + .get(superclass_ref_id) + .unwrap() + .offset() + .clone(), + format!( + "Parent of class `{}` redefined from `{}` to `{}`", + self.graph.declarations().get(&declaration_id).unwrap().name(), + self.graph.declarations().get(&first_parent_id).unwrap().name(), + self.graph.declarations().get(parent_declaration_id).unwrap().name() + ), + ); + + self.graph + .declarations_mut() + .get_mut(&declaration_id) + .unwrap() + .add_diagnostic(diagnostic); + } + } + + explicit_parents.first().map_or( + (*OBJECT_ID, None, partial), + |(parent_id, superclass_ref_id, definition_id)| { + let namespace_id = self.resolve_to_namespace(*parent_id).unwrap_or(*OBJECT_ID); + (namespace_id, Some((*superclass_ref_id, *definition_id)), partial) + }, + ) } fn linearize_parent_class( &mut self, + declaration_id: DeclarationId, definition_ids: &[DefinitionId], context: &mut LinearizationContext, ) -> Ancestors { - let (picked_parent, partial) = self.get_parent_class(definition_ids); + let (picked_parent, parent_info, partial) = self.get_parent_class(declaration_id, definition_ids); + let result = self.linearize_ancestors(picked_parent, context); + + if matches!(result, Ancestors::Cyclic(_)) + && let Some((superclass_ref_id, definition_id)) = parent_info + { + let diagnostic = Diagnostic::new( + Rule::CircularDependency, + *self.graph.definitions().get(&definition_id).unwrap().uri_id(), + self.graph + .constant_references() + .get(&superclass_ref_id) + .unwrap() + .offset() + .clone(), + format!( + "Circular dependency: `{}` is parent of itself", + self.graph.declarations().get(&declaration_id).unwrap().name() + ), + ); + + self.graph + .declarations_mut() + .get_mut(&declaration_id) + .unwrap() + .add_diagnostic(diagnostic); + } + if partial { result.to_partial() } else { result } } @@ -1830,7 +2071,11 @@ mod tests { }); context.resolve(); - assert_no_diagnostics!(&context, &[Rule::ParseWarning]); + assert_diagnostics_eq!( + &context, + vec!["unresolved-constant-reference: Unresolved constant reference: `Foo` (1:1-1:4)"], + &[Rule::ParseWarning] + ); let reference = context.graph().constant_references().values().next().unwrap(); @@ -1962,7 +2207,9 @@ mod tests { }); context.resolve(); - assert_no_diagnostics!(&context); + // Foo resolves to a Todo placeholder (implicit namespace), so no diagnostic is emitted. + // The Todo declaration itself is the signal that Foo was never explicitly defined. + assert_no_diagnostics!(&context, &[Rule::ParseWarning]); assert_declaration_kind_eq!(context, "Foo", ""); @@ -2296,7 +2543,14 @@ mod tests { }); context.resolve(); - assert_no_diagnostics!(&context); + assert_diagnostics_eq!( + &context, + vec![ + "circular-dependency: Circular dependency: `Foo` is parent of itself (1:13-1:16)", + "circular-dependency: Circular dependency: `Bar` is parent of itself (2:13-2:16)", + "circular-dependency: Circular dependency: `Baz` is parent of itself (3:13-3:16)" + ] + ); assert_ancestors_eq!(context, "Foo", ["Foo", "Bar", "Baz", "Object"]); } @@ -2334,7 +2588,14 @@ mod tests { }); context.resolve(); - assert_no_diagnostics!(&context); + assert_diagnostics_eq!( + &context, + vec![ + "unresolved-constant-reference: Unresolved constant reference: `Foo` (1:13-1:16)", + "unresolved-constant-reference: Unresolved constant reference: `CONST` (2:3-2:8)", + ], + &[Rule::ParseWarning] + ); let declaration = context.graph().declarations().get(&DeclarationId::from("Bar")).unwrap(); assert!(matches!( @@ -2666,7 +2927,7 @@ mod tests { }); context.resolve(); - assert_no_diagnostics!(&context); + assert_no_diagnostics!(&context, &[Rule::UnresolvedConstantReference]); assert_ancestors_eq!(context, "B", ["B"]); // TODO: this is a temporary hack to avoid crashing on `Struct.new`, `Class.new` and `Module.new` @@ -2709,7 +2970,10 @@ mod tests { }); context.resolve(); - assert_no_diagnostics!(&context); + assert_diagnostics_eq!( + &context, + vec!["circular-dependency: Circular dependency: `Foo` is parent of itself (2:11-2:14)"] + ); assert_ancestors_eq!(context, "Foo", ["Foo"]); } @@ -2959,7 +3223,7 @@ mod tests { }); context.resolve(); - assert_no_diagnostics!(&context); + assert_no_diagnostics!(&context, &[Rule::UnresolvedConstantReference]); assert_ancestors_eq!(context, "B", ["B"]); // TODO: this is a temporary hack to avoid crashing on `Struct.new`, `Class.new` and `Module.new` @@ -2980,7 +3244,10 @@ mod tests { }); context.resolve(); - assert_no_diagnostics!(&context); + assert_diagnostics_eq!( + &context, + vec!["circular-dependency: Circular dependency: `Foo` is parent of itself (2:11-2:14)"] + ); assert_ancestors_eq!(context, "Foo", ["Foo"]); } @@ -3688,7 +3955,11 @@ mod tests { }); context.resolve(); - assert_no_diagnostics!(&context); + assert_diagnostics_eq!( + &context, + vec!["unresolved-constant-reference: Unresolved constant reference: `NonExistent` (1:11-1:22)"], + &[Rule::ParseWarning] + ); assert_constant_alias_target_eq!(context, "ALIAS_2", "ALIAS_1"); assert_no_constant_alias_target!(context, "ALIAS_1"); @@ -3706,7 +3977,11 @@ mod tests { }); context.resolve(); - assert_no_diagnostics!(&context, &[Rule::ParseWarning]); + assert_diagnostics_eq!( + &context, + vec!["unresolved-constant-reference: Unresolved constant reference: `NOPE` (3:8-3:12)"], + &[Rule::ParseWarning] + ); assert_constant_alias_target_eq!(context, "ALIAS", "VALUE"); @@ -4321,7 +4596,11 @@ mod tests { }); context.resolve(); - assert_no_diagnostics!(&context); + assert_diagnostics_eq!( + &context, + vec!["non-module-mixin: Mixin `B` is not a module (5:11-5:12)"], + &[Rule::ParseWarning] + ); assert_ancestors_eq!(context, "C", ["C", "O::A", "B", "Object"]); assert_constant_reference_to!(context, "O::A::X", "file:///1.rb:7:3-7:4"); @@ -4453,7 +4732,11 @@ mod tests { }); context.resolve(); - assert_no_diagnostics!(&context); + assert_diagnostics_eq!( + &context, + vec!["unresolved-constant-reference: Unresolved constant reference: `Foo` (1:1-1:4)"], + &[Rule::ParseWarning] + ); assert_declaration_does_not_exist!(context, "Foo::"); } @@ -4601,15 +4884,15 @@ mod tests { assert_no_diagnostics!(&context); assert_declaration_exists!(context, "FOO"); - assert_declaration_kind_eq!(context, "FOO", "Constant"); + assert_declaration_kind_eq!(context, "FOO", "constant"); assert_owner_eq!(context, "FOO", "Object"); assert_declaration_exists!(context, "Bar::BAZ"); - assert_declaration_kind_eq!(context, "Bar::BAZ", "Constant"); + assert_declaration_kind_eq!(context, "Bar::BAZ", "constant"); assert_owner_eq!(context, "Bar::BAZ", "Bar"); assert_declaration_exists!(context, "Bar::QUX"); - assert_declaration_kind_eq!(context, "Bar::QUX", "Constant"); + assert_declaration_kind_eq!(context, "Bar::QUX", "constant"); assert_owner_eq!(context, "Bar::QUX", "Bar"); } @@ -4689,7 +4972,14 @@ mod tests { }); context.resolve(); - assert_no_diagnostics!(&context); + assert_diagnostics_eq!( + &context, + vec![ + "unresolved-constant-reference: Unresolved constant reference: `Protobuf` (1:7-1:15)", + "unresolved-constant-reference: Unresolved constant reference: `Protobuf` (2:12-2:20)", + ], + &[Rule::ParseWarning] + ); } #[test] @@ -4805,7 +5095,7 @@ mod tests { context.resolve(); assert_no_diagnostics!(&context); - assert_declaration_kind_eq!(context, "FOO", "Constant"); + assert_declaration_kind_eq!(context, "FOO", "constant"); } #[test] @@ -4839,7 +5129,7 @@ mod tests { context.resolve(); assert_no_diagnostics!(&context); - assert_declaration_kind_eq!(context, "Foo", "Constant"); + assert_declaration_kind_eq!(context, "Foo", "constant"); } #[test] @@ -4874,7 +5164,7 @@ mod tests { context.resolve(); assert_no_diagnostics!(&context); - assert_declaration_kind_eq!(context, "Foo", "Class"); + assert_declaration_kind_eq!(context, "Foo", "class"); } #[test] @@ -5275,9 +5565,9 @@ mod tests { }); context.resolve(); - assert_declaration_kind_eq!(context, "Foo", "Module"); - assert_declaration_kind_eq!(context, "Foo::Bar", "Module"); - assert_declaration_kind_eq!(context, "Foo::Bar::Baz", "Constant"); + assert_declaration_kind_eq!(context, "Foo", "module"); + assert_declaration_kind_eq!(context, "Foo::Bar", "module"); + assert_declaration_kind_eq!(context, "Foo::Bar::Baz", "constant"); } #[test] @@ -5294,7 +5584,7 @@ mod tests { }); context.resolve(); - assert_declaration_kind_eq!(context, "Foo", "Module"); + assert_declaration_kind_eq!(context, "Foo", "module"); assert_declaration_exists!(context, "Foo::#bar()"); } @@ -5312,7 +5602,7 @@ mod tests { }); context.resolve(); - assert_declaration_kind_eq!(context, "Foo", "Constant"); + assert_declaration_kind_eq!(context, "Foo", "constant"); assert_declaration_does_not_exist!(context, "Foo::"); assert_declaration_does_not_exist!(context, "Foo::#bar()"); } @@ -5364,7 +5654,7 @@ mod tests { assert_no_diagnostics!(&context); // Foo was initially created as a Todo (from class Foo::Bar), then promoted to Class - assert_declaration_kind_eq!(context, "Foo", "Class"); + assert_declaration_kind_eq!(context, "Foo", "class"); assert_members_eq!(context, "Object", vec!["Foo"]); assert_members_eq!(context, "Foo", vec!["Bar", "foo()"]); @@ -5398,7 +5688,7 @@ mod tests { assert_no_diagnostics!(&context); // Foo was promoted from Todo to Class after the second resolution - assert_declaration_kind_eq!(context, "Foo", "Class"); + assert_declaration_kind_eq!(context, "Foo", "class"); assert_members_eq!(context, "Object", vec!["Foo"]); assert_members_eq!(context, "Foo", vec!["Bar", "foo()"]); @@ -5423,7 +5713,7 @@ mod tests { context.resolve(); assert_no_diagnostics!(&context); - assert_declaration_kind_eq!(context, "Bar", "Module"); + assert_declaration_kind_eq!(context, "Bar", "module"); assert_members_eq!(context, "Bar", vec!["Baz"]); assert_declaration_exists!(context, "Bar::Baz"); assert_members_eq!(context, "Bar::Baz", vec!["qux()"]); @@ -5458,9 +5748,137 @@ mod tests { // After discovering top-level Bar, the Todo should be promoted and Baz re-homed. assert_no_diagnostics!(&context); - assert_declaration_kind_eq!(context, "Bar", "Module"); + assert_declaration_kind_eq!(context, "Bar", "module"); assert_members_eq!(context, "Bar", vec!["Baz"]); assert_members_eq!(context, "Bar::Baz", vec!["qux()"]); assert_declaration_does_not_exist!(context, "Foo::Bar"); } + + // Diagnostics tests + + #[test] + fn resolution_diagnostics_for_kind_redefinition() { + let mut context = GraphTest::new(); + context.index_uri("file:///foo.rb", { + r" + module Foo; end + class Foo; end + + class Bar; end + module Bar; end + + class Baz; end + Baz = 123 + + module Qux; end + module Qux; end + + def foo; end + attr_reader :foo + + class Qaz + class Array; end + def Array; end + end + " + }); + + context.resolve(); + + assert_diagnostics_eq!( + &context, + vec![ + "kind-redefinition: Redefining `Foo` as `class`, previously defined as `module` (2:1-2:15)", + "kind-redefinition: Redefining `Bar` as `module`, previously defined as `class` (5:1-5:16)", + "kind-redefinition: Redefining `Baz` as `constant`, previously defined as `class` (8:1-8:4)", + ] + ); + } + + #[test] + fn resolution_diagnostics_for_parent_redefinition() { + let mut context = GraphTest::new(); + context.index_uri("file:///foo.rb", { + r" + class Parent1; end + class Parent2; end + + class Child1; end + class Child1; end + class Child1 < Object; end + class Child1 < ::Object; end + + class Child2; end + class Child2 < Parent1; end + class ::Child2 < ::Parent1; end + + class Child3; end + class Child3 < Parent1; end + class Child3 < Parent2; end + " + }); + + context.resolve(); + + assert_diagnostics_eq!( + &context, + vec![ + // FIXME: Object should resolve + "unresolved-constant-reference: Unresolved constant reference: `Object` (6:16-6:22)", + "unresolved-constant-reference: Unresolved constant reference: `Object` (7:16-7:24)", + "parent-redefinition: Parent of class `Child3` redefined from `Parent1` to `Parent2` (15:16-15:23)", + ] + ); + } + + #[test] + fn resolution_diagnostics_for_non_class_superclass() { + let mut context = GraphTest::new(); + context.index_uri("file:///foo.rb", { + r" + module Parent1; end + Parent2 = 42 + + class Child1 < Parent1; end + class Child2 < Parent2; end + " + }); + + context.resolve(); + + assert_diagnostics_eq!( + &context, + vec![ + "non-class-superclass: Superclass `Parent1` of `Child1` is not a class (found `module`) (4:16-4:23)", + "non-class-superclass: Superclass `Parent2` of `Child2` is not a class (found `constant`) (5:16-5:23)", + ] + ); + } + + #[test] + fn resolution_diagnostics_for_non_module_mixin() { + let mut context = GraphTest::new(); + context.index_uri("file:///foo.rb", { + r" + class Mixin; end + + class Child + include Mixin; + prepend Mixin; + extend Mixin; + end + " + }); + + context.resolve(); + + assert_diagnostics_eq!( + &context, + vec![ + "non-module-mixin: Mixin `Mixin` is not a module (4:11-4:16)", + "non-module-mixin: Mixin `Mixin` is not a module (5:11-5:16)", + // "non-module-mixin: Mixin `Mixin` is not a module (6:10-6:15)", FIXME: we should report this once we linearize the ancestors of singleton classes + ] + ); + } } diff --git a/rust/rubydex/src/stats/orphan_report.rs b/rust/rubydex/src/stats/orphan_report.rs index b95f96dfd..7b4b674ee 100644 --- a/rust/rubydex/src/stats/orphan_report.rs +++ b/rust/rubydex/src/stats/orphan_report.rs @@ -33,7 +33,7 @@ impl Graph { // Sort by type, then by location for consistent output orphans.sort_by(|(_, a), (_, b)| { a.kind() - .cmp(b.kind()) + .cmp(&b.kind()) .then_with(|| a.uri_id().cmp(b.uri_id())) .then_with(|| a.offset().cmp(b.offset())) }); @@ -171,6 +171,7 @@ impl Graph { #[cfg(test)] mod tests { + use crate::model::definitions::DefinitionKind; use crate::test_utils::GraphTest; #[test] @@ -212,7 +213,7 @@ mod tests { .graph() .definitions() .values() - .find(|d| d.kind() == "Method" && d.name_id().is_none()) + .find(|d| d.kind() == DefinitionKind::Method && d.name_id().is_none()) .unwrap_or_else(|| panic!("No Method definition without name_id found for source: {source}")); let actual = context.graph().build_concatenated_name_from_lexical_nesting(definition); @@ -229,7 +230,7 @@ mod tests { .graph() .definitions() .values() - .find(|d| d.kind() == "InstanceVariable") + .find(|d| d.kind() == DefinitionKind::InstanceVariable) .unwrap(); let actual = context.graph().build_concatenated_name_from_lexical_nesting(definition); @@ -253,7 +254,7 @@ mod tests { .graph() .definitions() .values() - .find(|d| d.kind() == "Method") + .find(|d| d.kind() == DefinitionKind::Method) .unwrap(); let actual = context.graph().definition_location(definition); diff --git a/rust/rubydex/src/test_utils/graph_test.rs b/rust/rubydex/src/test_utils/graph_test.rs index 534822cd0..0bfb34283 100644 --- a/rust/rubydex/src/test_utils/graph_test.rs +++ b/rust/rubydex/src/test_utils/graph_test.rs @@ -96,7 +96,7 @@ macro_rules! assert_declaration_kind_eq { .get(&$crate::model::ids::DeclarationId::from($declaration_name)) .unwrap(); assert_eq!( - declaration.kind(), + declaration.kind().to_string(), $expected_kind, "Expected declaration `{}` to be a {}, got {}", $declaration_name, diff --git a/rust/rubydex/src/visualization/dot.rs b/rust/rubydex/src/visualization/dot.rs index 5cbf55064..846b98e04 100644 --- a/rust/rubydex/src/visualization/dot.rs +++ b/rust/rubydex/src/visualization/dot.rs @@ -160,8 +160,8 @@ mod tests { "Name:TestModule" [label="TestModule",shape=hexagon]; "Name:TestModule" -> "def_{module_def_id}" [dir=both]; - "def_{class_def_id}" [label="Class(TestClass)",shape=ellipse]; - "def_{module_def_id}" [label="Module(TestModule)",shape=ellipse]; + "def_{class_def_id}" [label="class(TestClass)",shape=ellipse]; + "def_{module_def_id}" [label="module(TestModule)",shape=ellipse]; "file:///test.rb" [label="test.rb",shape=box]; "def_{class_def_id}" -> "file:///test.rb"; diff --git a/rust/rubydex/tests/cli.rs b/rust/rubydex/tests/cli.rs index f77ca5fd8..b3bf268cd 100644 --- a/rust/rubydex/tests/cli.rs +++ b/rust/rubydex/tests/cli.rs @@ -101,7 +101,7 @@ fn visualize_simple_class() { "Name:SimpleClass" [label="SimpleClass",shape=hexagon]; "Name:SimpleClass" -> "def_" [dir=both]; - "def_" [label="Class(SimpleClass)",shape=ellipse]; + "def_" [label="class(SimpleClass)",shape=ellipse]; "file:///simple.rb" [label="simple.rb",shape=box]; "def_" -> "file:///simple.rb";