diff --git a/core/src/analyzer/definitions.rs b/core/src/analyzer/definitions.rs index 9b48b56..19b8e86 100644 --- a/core/src/analyzer/definitions.rs +++ b/core/src/analyzer/definitions.rs @@ -27,6 +27,17 @@ pub(crate) fn process_class_node( ) -> Option { let class_name = extract_class_name(class_node); let superclass = class_node.superclass().and_then(|sup| extract_constant_path(&sup)); + + // Warn if superclass is a dynamic expression (not a constant path) + // TODO: Replace eprintln! with structured diagnostic (record_type_error or warning) + // so this is visible in LSP mode and includes source location. + if class_node.superclass().is_some() && superclass.is_none() { + eprintln!( + "[methodray] warning: dynamic superclass expression in class {}; inheritance will be ignored", + class_name + ); + } + install_class(genv, class_name, superclass.as_deref()); if let Some(body) = class_node.body() { diff --git a/core/src/analyzer/dispatch.rs b/core/src/analyzer/dispatch.rs index a1df3f0..9cfc91c 100644 --- a/core/src/analyzer/dispatch.rs +++ b/core/src/analyzer/dispatch.rs @@ -1410,6 +1410,82 @@ User.new.foo // === Safe navigation operator (`&.`) tests === + // === Inheritance chain tests === + + #[test] + fn test_inheritance_basic() { + let source = r#" +class Animal + def speak + "..." + end +end + +class Dog < Animal +end + +Dog.new.speak +"#; + let genv = analyze(source); + assert!( + genv.type_errors.is_empty(), + "Dog.new.speak should resolve via Animal#speak: {:?}", + genv.type_errors + ); + } + + #[test] + fn test_inheritance_multi_level() { + let source = r#" +class Animal + def speak + "..." + end +end + +class Dog < Animal +end + +class Puppy < Dog +end + +Puppy.new.speak +"#; + let genv = analyze(source); + assert!( + genv.type_errors.is_empty(), + "Puppy.new.speak should resolve via Animal#speak: {:?}", + genv.type_errors + ); + } + + #[test] + fn test_inheritance_override() { + let source = r#" +class Animal + def speak + "generic" + end +end + +class Dog < Animal + def speak + 42 + end +end + +Dog.new.speak +"#; + let genv = analyze(source); + assert!(genv.type_errors.is_empty()); + + let info = genv + .resolve_method(&Type::instance("Dog"), "speak") + .expect("Dog#speak should be resolved"); + let ret_vtx = info.return_vertex.unwrap(); + assert_eq!(get_type_show(&genv, ret_vtx), "Integer"); + } + #[test] fn test_safe_navigation_basic() { let source = r#" diff --git a/core/src/env/global_env.rs b/core/src/env/global_env.rs index a822201..a7eeaa8 100644 --- a/core/src/env/global_env.rs +++ b/core/src/env/global_env.rs @@ -6,7 +6,7 @@ use std::collections::HashMap; use crate::env::box_manager::BoxManager; -use crate::env::method_registry::{MethodInfo, MethodRegistry}; +use crate::env::method_registry::{MethodInfo, MethodRegistry, ResolutionContext}; use crate::env::scope::{Scope, ScopeId, ScopeKind, ScopeManager}; use crate::env::type_error::TypeError; use crate::env::vertex_manager::VertexManager; @@ -41,6 +41,8 @@ pub struct GlobalEnv { /// Module inclusions: class_name → Vec (in include order) module_inclusions: HashMap>, + /// Superclass map: child_class → parent_class + superclass_map: HashMap, } impl GlobalEnv { @@ -52,6 +54,7 @@ impl GlobalEnv { type_errors: Vec::new(), scope_manager: ScopeManager::new(), module_inclusions: HashMap::new(), + superclass_map: HashMap::new(), } } @@ -163,8 +166,12 @@ impl GlobalEnv { /// Resolve method pub fn resolve_method(&self, recv_ty: &Type, method_name: &str) -> Option<&MethodInfo> { + let ctx = ResolutionContext { + inclusions: &self.module_inclusions, + superclass_map: &self.superclass_map, + }; self.method_registry - .resolve(recv_ty, method_name, &self.module_inclusions) + .resolve(recv_ty, method_name, &ctx) } /// Record that a class includes a module @@ -247,6 +254,30 @@ impl GlobalEnv { }); self.scope_manager.enter_scope(scope_id); self.register_constant_in_parent(scope_id, &name); + + // Record superclass relationship in superclass_map + if let Some(parent) = superclass { + let child_name = self.scope_manager.current_qualified_name() + .unwrap_or_else(|| name.clone()); + // NOTE: lookup_constant may fail for cross-namespace inheritance + // (e.g., `class Dog < Animal` inside `module Service` where Animal is `Api::Animal`). + // In that case, the raw name is used. This is a known limitation (see design doc Q2). + let parent_name = self.scope_manager.lookup_constant(parent) + .unwrap_or_else(|| parent.to_string()); + + // Detect superclass mismatch (Ruby raises TypeError for this at runtime) + if let Some(existing) = self.superclass_map.get(&child_name) { + if *existing != parent_name { + eprintln!( + "[methodray] warning: superclass mismatch for {}: previously {}, now {}", + child_name, existing, parent_name + ); + } + } + + self.superclass_map.insert(child_name, parent_name); + } + scope_id } diff --git a/core/src/env/method_registry.rs b/core/src/env/method_registry.rs index 676ca41..8703efa 100644 --- a/core/src/env/method_registry.rs +++ b/core/src/env/method_registry.rs @@ -1,6 +1,6 @@ //! Method registration and resolution -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use crate::graph::VertexId; use crate::types::Type; @@ -9,6 +9,27 @@ use smallvec::SmallVec; const OBJECT_CLASS: &str = "Object"; const KERNEL_MODULE: &str = "Kernel"; +/// Aggregated context for method resolution (inclusions, superclass chain) +pub struct ResolutionContext<'a> { + pub inclusions: &'a HashMap>, + pub superclass_map: &'a HashMap, +} + +impl<'a> ResolutionContext<'a> { + #[cfg(test)] + pub fn empty() -> Self { + use std::sync::LazyLock; + static EMPTY_VEC_MAP: LazyLock>> = + LazyLock::new(HashMap::new); + static EMPTY_STRING_MAP: LazyLock> = + LazyLock::new(HashMap::new); + Self { + inclusions: &EMPTY_VEC_MAP, + superclass_map: &EMPTY_STRING_MAP, + } + } +} + /// Method information #[derive(Debug, Clone)] pub struct MethodInfo { @@ -79,17 +100,31 @@ impl MethodRegistry { ); } + /// Add included modules for a given class name to the chain. + fn add_included_modules( + chain: &mut SmallVec<[Type; 8]>, + class_name: &str, + inclusions: &HashMap>, + ) { + if let Some(modules) = inclusions.get(class_name) { + for module_name in modules.iter().rev() { + chain.push(Type::instance(module_name)); + } + } + } + /// Build the method resolution order (MRO) fallback chain for a receiver type. /// /// Returns a list of types to search in order: /// 1. Exact receiver type /// 2. Generic → base class (e.g., Array[Integer] → Array) - /// 3. Included modules (last included first, matching Ruby MRO) - /// 4. Object (for Instance/Generic types only) - /// 5. Kernel (for Instance/Generic types only) + /// 3. Included modules of self (last included first, matching Ruby MRO) + /// 4. Superclass chain: for each parent, add parent type + its included modules + /// 5. Object (for Instance/Generic types only) + /// 6. Kernel (for Instance/Generic types only) fn fallback_chain( recv_ty: &Type, - inclusions: &HashMap>, + ctx: &ResolutionContext, ) -> SmallVec<[Type; 8]> { let mut chain = SmallVec::new(); chain.push(recv_ty.clone()); @@ -98,14 +133,24 @@ impl MethodRegistry { chain.push(Type::Instance { name: name.clone() }); } - // MRO for Instance/Generic: included modules → Object → Kernel + // MRO for Instance/Generic: included modules → superclass chain → Object → Kernel if matches!(recv_ty, Type::Instance { .. } | Type::Generic { .. }) { - // Included modules (reverse order = last included has highest priority) + // Included modules of self (reverse order = last included has highest priority) if let Some(class_name) = recv_ty.base_class_name() { - if let Some(modules) = inclusions.get(class_name) { - for module_name in modules.iter().rev() { - chain.push(Type::instance(module_name)); + Self::add_included_modules(&mut chain, class_name, ctx.inclusions); + + // Walk superclass chain + let mut visited = HashSet::new(); + visited.insert(class_name.to_string()); + let mut current = class_name.to_string(); + while let Some(parent) = ctx.superclass_map.get(¤t) { + if !visited.insert(parent.clone()) { + // Cycle detected, stop walking + break; } + chain.push(Type::instance(parent)); + Self::add_included_modules(&mut chain, parent, ctx.inclusions); + current = parent.clone(); } } @@ -119,16 +164,16 @@ impl MethodRegistry { /// Resolve a method for a receiver type /// /// Searches the MRO fallback chain: exact type → base class (for generics) - /// → included modules → Object → Kernel. + /// → included modules → superclass chain → Object → Kernel. /// For non-instance types (Singleton, Nil, Union, Bot), only exact match is attempted. pub fn resolve( &self, recv_ty: &Type, method_name: &str, - inclusions: &HashMap>, + ctx: &ResolutionContext, ) -> Option<&MethodInfo> { let method_key = method_name.to_string(); - Self::fallback_chain(recv_ty, inclusions) + Self::fallback_chain(recv_ty, ctx) .into_iter() .find_map(|ty| self.methods.get(&(ty, method_key.clone()))) } @@ -143,14 +188,14 @@ mod tests { let mut registry = MethodRegistry::new(); registry.register(Type::string(), "length", Type::integer()); - let info = registry.resolve(&Type::string(), "length", &HashMap::new()).unwrap(); + let info = registry.resolve(&Type::string(), "length", &ResolutionContext::empty()).unwrap(); assert_eq!(info.return_type.base_class_name(), Some("Integer")); } #[test] fn test_resolve_not_found() { let registry = MethodRegistry::new(); - assert!(registry.resolve(&Type::string(), "unknown", &HashMap::new()).is_none()); + assert!(registry.resolve(&Type::string(), "unknown", &ResolutionContext::empty()).is_none()); } #[test] @@ -159,7 +204,7 @@ mod tests { let return_vtx = VertexId(42); registry.register_user_method(Type::instance("User"), "name", return_vtx, vec![], None); - let info = registry.resolve(&Type::instance("User"), "name", &HashMap::new()).unwrap(); + let info = registry.resolve(&Type::instance("User"), "name", &ResolutionContext::empty()).unwrap(); assert_eq!(info.return_vertex, Some(VertexId(42))); assert_eq!(info.return_type, Type::Bot); } @@ -177,7 +222,7 @@ mod tests { None, ); - let info = registry.resolve(&Type::instance("Calc"), "add", &HashMap::new()).unwrap(); + let info = registry.resolve(&Type::instance("Calc"), "add", &ResolutionContext::empty()).unwrap(); assert_eq!(info.return_vertex, Some(VertexId(10))); let pvs = info.param_vertices.as_ref().unwrap(); assert_eq!(pvs.len(), 2); @@ -191,7 +236,7 @@ mod tests { fn test_resolve_falls_back_to_object() { let mut registry = MethodRegistry::new(); registry.register(Type::instance("Object"), "nil?", Type::instance("TrueClass")); - let info = registry.resolve(&Type::instance("CustomClass"), "nil?", &HashMap::new()).unwrap(); + let info = registry.resolve(&Type::instance("CustomClass"), "nil?", &ResolutionContext::empty()).unwrap(); assert_eq!(info.return_type.base_class_name(), Some("TrueClass")); } @@ -199,7 +244,7 @@ mod tests { fn test_resolve_falls_back_to_kernel() { let mut registry = MethodRegistry::new(); registry.register(Type::instance("Kernel"), "puts", Type::Nil); - let info = registry.resolve(&Type::instance("MyApp"), "puts", &HashMap::new()).unwrap(); + let info = registry.resolve(&Type::instance("MyApp"), "puts", &ResolutionContext::empty()).unwrap(); assert_eq!(info.return_type, Type::Nil); } @@ -208,7 +253,7 @@ mod tests { let mut registry = MethodRegistry::new(); registry.register(Type::instance("Object"), "to_s", Type::string()); registry.register(Type::instance("Kernel"), "to_s", Type::integer()); - let info = registry.resolve(&Type::instance("Anything"), "to_s", &HashMap::new()).unwrap(); + let info = registry.resolve(&Type::instance("Anything"), "to_s", &ResolutionContext::empty()).unwrap(); assert_eq!(info.return_type.base_class_name(), Some("String")); } @@ -217,7 +262,7 @@ mod tests { let mut registry = MethodRegistry::new(); registry.register(Type::string(), "length", Type::integer()); registry.register(Type::instance("Object"), "length", Type::string()); - let info = registry.resolve(&Type::string(), "length", &HashMap::new()).unwrap(); + let info = registry.resolve(&Type::string(), "length", &ResolutionContext::empty()).unwrap(); assert_eq!(info.return_type.base_class_name(), Some("Integer")); } @@ -227,14 +272,14 @@ mod tests { fn test_singleton_type_skips_fallback() { let mut registry = MethodRegistry::new(); registry.register(Type::instance("Kernel"), "puts", Type::Nil); - assert!(registry.resolve(&Type::singleton("User"), "puts", &HashMap::new()).is_none()); + assert!(registry.resolve(&Type::singleton("User"), "puts", &ResolutionContext::empty()).is_none()); } #[test] fn test_nil_type_skips_fallback() { let mut registry = MethodRegistry::new(); registry.register(Type::instance("Kernel"), "puts", Type::Nil); - assert!(registry.resolve(&Type::Nil, "puts", &HashMap::new()).is_none()); + assert!(registry.resolve(&Type::Nil, "puts", &ResolutionContext::empty()).is_none()); } #[test] @@ -242,14 +287,14 @@ mod tests { let mut registry = MethodRegistry::new(); registry.register(Type::instance("Kernel"), "puts", Type::Nil); let union_ty = Type::Union(vec![Type::string(), Type::integer()]); - assert!(registry.resolve(&union_ty, "puts", &HashMap::new()).is_none()); + assert!(registry.resolve(&union_ty, "puts", &ResolutionContext::empty()).is_none()); } #[test] fn test_bot_type_skips_fallback() { let mut registry = MethodRegistry::new(); registry.register(Type::instance("Kernel"), "puts", Type::Nil); - assert!(registry.resolve(&Type::Bot, "puts", &HashMap::new()).is_none()); + assert!(registry.resolve(&Type::Bot, "puts", &ResolutionContext::empty()).is_none()); } // --- Generic type fallback chain --- @@ -259,7 +304,7 @@ mod tests { let mut registry = MethodRegistry::new(); registry.register(Type::instance("Kernel"), "puts", Type::Nil); let generic_type = Type::array_of(Type::integer()); - let info = registry.resolve(&generic_type, "puts", &HashMap::new()).unwrap(); + let info = registry.resolve(&generic_type, "puts", &ResolutionContext::empty()).unwrap(); assert_eq!(info.return_type, Type::Nil); } @@ -270,7 +315,7 @@ mod tests { registry.register(Type::instance("Kernel"), "object_id", Type::integer()); let generic_type = Type::array_of(Type::string()); // Array[String] → Array (none) → Object (none) → Kernel (exists) - let info = registry.resolve(&generic_type, "object_id", &HashMap::new()).unwrap(); + let info = registry.resolve(&generic_type, "object_id", &ResolutionContext::empty()).unwrap(); assert_eq!(info.return_type.base_class_name(), Some("Integer")); } @@ -280,7 +325,7 @@ mod tests { fn test_resolve_namespaced_class_falls_back_to_object() { let mut registry = MethodRegistry::new(); registry.register(Type::instance("Object"), "class", Type::string()); - let info = registry.resolve(&Type::instance("Api::V1::User"), "class", &HashMap::new()).unwrap(); + let info = registry.resolve(&Type::instance("Api::V1::User"), "class", &ResolutionContext::empty()).unwrap(); assert_eq!(info.return_type.base_class_name(), Some("String")); } @@ -293,8 +338,12 @@ mod tests { let mut inclusions = HashMap::new(); inclusions.insert("User".to_string(), vec!["Greetable".to_string()]); + let ctx = ResolutionContext { + inclusions: &inclusions, + superclass_map: &HashMap::new(), + }; - let info = registry.resolve(&Type::instance("User"), "greet", &inclusions).unwrap(); + let info = registry.resolve(&Type::instance("User"), "greet", &ctx).unwrap(); assert_eq!(info.return_type.base_class_name(), Some("String")); } @@ -307,8 +356,12 @@ mod tests { let mut inclusions = HashMap::new(); inclusions.insert("User".to_string(), vec!["A".to_string(), "B".to_string()]); + let ctx = ResolutionContext { + inclusions: &inclusions, + superclass_map: &HashMap::new(), + }; - let info = registry.resolve(&Type::instance("User"), "foo", &inclusions).unwrap(); + let info = registry.resolve(&Type::instance("User"), "foo", &ctx).unwrap(); assert_eq!(info.return_type.base_class_name(), Some("Integer")); } @@ -321,8 +374,12 @@ mod tests { let mut inclusions = HashMap::new(); inclusions.insert("User".to_string(), vec!["Greetable".to_string()]); + let ctx = ResolutionContext { + inclusions: &inclusions, + superclass_map: &HashMap::new(), + }; - let info = registry.resolve(&Type::instance("User"), "greet", &inclusions).unwrap(); + let info = registry.resolve(&Type::instance("User"), "greet", &ctx).unwrap(); assert_eq!(info.return_type.base_class_name(), Some("Integer")); } @@ -335,8 +392,12 @@ mod tests { let mut inclusions = HashMap::new(); inclusions.insert("User".to_string(), vec!["MyModule".to_string()]); + let ctx = ResolutionContext { + inclusions: &inclusions, + superclass_map: &HashMap::new(), + }; - let info = registry.resolve(&Type::instance("User"), "foo", &inclusions).unwrap(); + let info = registry.resolve(&Type::instance("User"), "foo", &ctx).unwrap(); assert_eq!(info.return_type.base_class_name(), Some("Integer")); } @@ -348,7 +409,102 @@ mod tests { let mut inclusions = HashMap::new(); inclusions.insert("User".to_string(), vec!["Greetable".to_string()]); + let ctx = ResolutionContext { + inclusions: &inclusions, + superclass_map: &HashMap::new(), + }; + + assert!(registry.resolve(&Type::singleton("User"), "greet", &ctx).is_none()); + } + + // --- Superclass chain tests --- + + #[test] + fn test_resolve_with_superclass() { + // Dog < Animal: Dog.new can call Animal methods + let mut registry = MethodRegistry::new(); + registry.register(Type::instance("Animal"), "speak", Type::string()); + + let mut superclass_map = HashMap::new(); + superclass_map.insert("Dog".to_string(), "Animal".to_string()); + let ctx = ResolutionContext { + inclusions: &HashMap::new(), + superclass_map: &superclass_map, + }; + + let info = registry.resolve(&Type::instance("Dog"), "speak", &ctx).unwrap(); + assert_eq!(info.return_type.base_class_name(), Some("String")); + } + + #[test] + fn test_resolve_multi_level_inheritance() { + // Puppy < Dog < Animal chain + let mut registry = MethodRegistry::new(); + registry.register(Type::instance("Animal"), "breathe", Type::string()); + + let mut superclass_map = HashMap::new(); + superclass_map.insert("Dog".to_string(), "Animal".to_string()); + superclass_map.insert("Puppy".to_string(), "Dog".to_string()); + let ctx = ResolutionContext { + inclusions: &HashMap::new(), + superclass_map: &superclass_map, + }; + + let info = registry.resolve(&Type::instance("Puppy"), "breathe", &ctx).unwrap(); + assert_eq!(info.return_type.base_class_name(), Some("String")); + } + + #[test] + fn test_resolve_override_takes_priority() { + // Dog overrides Animal method + let mut registry = MethodRegistry::new(); + registry.register(Type::instance("Animal"), "speak", Type::string()); + registry.register(Type::instance("Dog"), "speak", Type::integer()); + + let mut superclass_map = HashMap::new(); + superclass_map.insert("Dog".to_string(), "Animal".to_string()); + let ctx = ResolutionContext { + inclusions: &HashMap::new(), + superclass_map: &superclass_map, + }; + + let info = registry.resolve(&Type::instance("Dog"), "speak", &ctx).unwrap(); + assert_eq!(info.return_type.base_class_name(), Some("Integer")); + } + + #[test] + fn test_resolve_parent_include() { + // Dog < Animal where Animal includes Greetable + let mut registry = MethodRegistry::new(); + registry.register(Type::instance("Greetable"), "greet", Type::string()); + + let mut inclusions = HashMap::new(); + inclusions.insert("Animal".to_string(), vec!["Greetable".to_string()]); + let mut superclass_map = HashMap::new(); + superclass_map.insert("Dog".to_string(), "Animal".to_string()); + let ctx = ResolutionContext { + inclusions: &inclusions, + superclass_map: &superclass_map, + }; + + let info = registry.resolve(&Type::instance("Dog"), "greet", &ctx).unwrap(); + assert_eq!(info.return_type.base_class_name(), Some("String")); + } + + #[test] + fn test_resolve_circular_inheritance_no_infinite_loop() { + // Circular inheritance: A < B < A (should not infinite loop) + let registry = MethodRegistry::new(); + + let mut superclass_map = HashMap::new(); + superclass_map.insert("A".to_string(), "B".to_string()); + superclass_map.insert("B".to_string(), "A".to_string()); + let ctx = ResolutionContext { + inclusions: &HashMap::new(), + superclass_map: &superclass_map, + }; - assert!(registry.resolve(&Type::singleton("User"), "greet", &inclusions).is_none()); + // Should not hang; just returns None + assert!(registry.resolve(&Type::instance("A"), "missing", &ctx).is_none()); } } diff --git a/test/inheritance_test.rb b/test/inheritance_test.rb new file mode 100644 index 0000000..4e9551d --- /dev/null +++ b/test/inheritance_test.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require 'test_helper' + +class InheritanceTest < Minitest::Test + include CLITestHelper + + # ============================================ + # No Error + # ============================================ + + def test_inheritance_basic_no_error + source = <<~RUBY + class Animal + def speak + "..." + end + end + + class Dog < Animal + end + + Dog.new.speak + RUBY + + assert_no_check_errors(source) + end + + def test_inheritance_multi_level + source = <<~RUBY + class Animal + def speak + "..." + end + end + + class Dog < Animal + end + + class Puppy < Dog + end + + Puppy.new.speak + RUBY + + assert_no_check_errors(source) + end + + def test_inheritance_override + source = <<~RUBY + class Animal + def speak + "generic" + end + end + + class Dog < Animal + def speak + 42 + end + end + + Dog.new.speak + RUBY + + assert_no_check_errors(source) + end + + def test_inheritance_child_and_parent_include_mro + source = <<~RUBY + module Swimmable + def move + "swim" + end + end + + module Runnable + def move + "run" + end + end + + class Animal + include Swimmable + end + + class Dog < Animal + include Runnable + end + + Dog.new.move.upcase + RUBY + + assert_no_check_errors(source) + end + + # ============================================ + # Error Detection + # ============================================ + + def test_inheritance_undefined_method + source = <<~RUBY + class Animal + def speak + "..." + end + end + + class Dog < Animal + end + + Dog.new.fly + RUBY + + assert_check_error(source, method_name: 'fly', receiver_type: 'Dog') + end + + def test_inheritance_method_chain_type_error + source = <<~RUBY + class Animal + def speak + "hello" + end + end + + class Dog < Animal + end + + Dog.new.speak.even? + RUBY + + assert_check_error(source, method_name: 'even?', receiver_type: 'String') + end +end