diff --git a/.gitignore b/.gitignore index c79666a..35acbf9 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ Cargo.lock # Ruby lib/methodray/methodray.* +lib/methodray/methodray-cli* lib/methodray/*.{so,bundle,dll} *.gem .bundle/ diff --git a/lib/methodray.rb b/lib/methodray.rb index 1695811..ff8ff80 100644 --- a/lib/methodray.rb +++ b/lib/methodray.rb @@ -2,7 +2,7 @@ require 'rbs' require_relative 'methodray/version' -require_relative 'methodray/methodray' # ネイティブ拡張 +require_relative 'methodray/methodray' module MethodRay class Error < StandardError; end diff --git a/lib/methodray/methodray-cli b/lib/methodray/methodray-cli deleted file mode 100755 index e301d4b..0000000 Binary files a/lib/methodray/methodray-cli and /dev/null differ diff --git a/rust/src/analyzer/blocks.rs b/rust/src/analyzer/blocks.rs new file mode 100644 index 0000000..46c3dcf --- /dev/null +++ b/rust/src/analyzer/blocks.rs @@ -0,0 +1,94 @@ +//! Block Handlers - Processing Ruby blocks +//! +//! This module is responsible for: +//! - Processing BlockNode (e.g., `{ |x| x.to_s }` or `do |x| x.to_s end`) +//! - Registering block parameters as local variables +//! - Managing block scope + +use crate::env::{GlobalEnv, LocalEnv, ScopeKind}; +use crate::graph::VertexId; + +use super::parameters::install_required_parameter; + +/// Enter a new block scope +/// +/// Creates a new scope for the block and enters it. +/// Block scopes inherit variables from parent scopes. +pub fn enter_block_scope(genv: &mut GlobalEnv) { + let block_scope_id = genv.scope_manager.new_scope(ScopeKind::Block); + genv.scope_manager.enter_scope(block_scope_id); +} + +/// Exit the current block scope +pub fn exit_block_scope(genv: &mut GlobalEnv) { + genv.scope_manager.exit_scope(); +} + +/// Install block parameters as local variables +/// +/// Block parameters are registered as Bot (untyped) type since we don't +/// know what type will be passed from the iterator method. +/// +/// # Example +/// ```ruby +/// [1, 2, 3].each { |x| x.to_s } # 'x' is a block parameter +/// ``` +pub fn install_block_parameter(genv: &mut GlobalEnv, lenv: &mut LocalEnv, name: String) -> VertexId { + // Reuse required parameter logic (Bot type) + install_required_parameter(genv, lenv, name) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_enter_exit_block_scope() { + let mut genv = GlobalEnv::new(); + + let initial_scope_id = genv.scope_manager.current_scope().id; + + enter_block_scope(&mut genv); + let block_scope_id = genv.scope_manager.current_scope().id; + + // Should be in a new scope + assert_ne!(initial_scope_id, block_scope_id); + + exit_block_scope(&mut genv); + + // Should be back to initial scope + assert_eq!(genv.scope_manager.current_scope().id, initial_scope_id); + } + + #[test] + fn test_install_block_parameter() { + let mut genv = GlobalEnv::new(); + let mut lenv = LocalEnv::new(); + + enter_block_scope(&mut genv); + + let vtx = install_block_parameter(&mut genv, &mut lenv, "x".to_string()); + + // Parameter should be registered in LocalEnv + assert_eq!(lenv.get_var("x"), Some(vtx)); + + exit_block_scope(&mut genv); + } + + #[test] + fn test_block_inherits_parent_scope_vars() { + let mut genv = GlobalEnv::new(); + + // Set variable in top-level scope + genv.scope_manager + .current_scope_mut() + .set_local_var("outer".to_string(), VertexId(100)); + + enter_block_scope(&mut genv); + + // Block should be able to lookup parent scope variables + assert_eq!(genv.scope_manager.lookup_var("outer"), Some(VertexId(100))); + + exit_block_scope(&mut genv); + } +} diff --git a/rust/src/analyzer/dispatch.rs b/rust/src/analyzer/dispatch.rs index b5b9bf6..7b455a4 100644 --- a/rust/src/analyzer/dispatch.rs +++ b/rust/src/analyzer/dispatch.rs @@ -9,7 +9,6 @@ use crate::source_map::SourceLocation; use ruby_prism::Node; use super::calls::install_method_call; -use super::literals::install_literal; use super::variables::{ install_ivar_read, install_ivar_write, install_local_var_read, install_local_var_write, install_self, @@ -34,10 +33,15 @@ pub enum NeedsChildKind<'a> { receiver: Node<'a>, method_name: String, location: SourceLocation, + /// Optional block attached to the method call + block: Option>, }, } /// First pass: check if node can be handled immediately without child processing +/// +/// Note: Literals (including Array) are handled in install.rs via install_literal +/// because Array literals need child processing for element type inference. pub fn dispatch_simple(genv: &mut GlobalEnv, lenv: &mut LocalEnv, node: &Node) -> DispatchResult { // Instance variable read: @name if let Some(ivar_read) = node.as_instance_variable_read_node() { @@ -62,11 +66,6 @@ pub fn dispatch_simple(genv: &mut GlobalEnv, lenv: &mut LocalEnv, node: &Node) - }; } - // Literals (String, Integer, Array, Hash, nil, true, false, Symbol) - if let Some(vtx) = install_literal(genv, node) { - return DispatchResult::Vertex(vtx); - } - DispatchResult::NotHandled } @@ -90,16 +89,21 @@ pub fn dispatch_needs_child<'a>(node: &Node<'a>, source: &str) -> Option AstInstaller<'a> { return self.install_def_node(&def_node); } + // Block node (standalone block, e.g., lambda { |x| x }) + if let Some(block_node) = node.as_block_node() { + return self.install_block_node(&block_node); + } + // Try simple dispatch first (no child processing needed) match dispatch_simple(self.genv, self.lenv, node) { DispatchResult::Vertex(vtx) => return Some(vtx), DispatchResult::NotHandled => {} } + // Literals (String, Integer, Array, Hash, nil, true, false, Symbol) + if let Some(vtx) = self.install_literal_node(node) { + return Some(vtx); + } + // Check if node needs child processing if let Some(kind) = dispatch_needs_child(node, self.source) { return self.process_needs_child(kind); @@ -62,6 +74,57 @@ impl<'a> AstInstaller<'a> { None } + /// Install literal node + /// + /// Handles all literals including Array with element type inference + fn install_literal_node(&mut self, node: &Node) -> Option { + // Array literals need special handling for element type inference + if node.as_array_node().is_some() { + let elements: Vec = node.as_array_node().unwrap().elements().iter().collect(); + return self.install_array_literal_elements(elements); + } + + // Other literals (String, Integer, Hash, nil, true, false, Symbol) + install_literal(self.genv, node) + } + + /// Install array literal with pre-collected elements + fn install_array_literal_elements(&mut self, elements: Vec) -> Option { + use crate::types::Type; + use std::collections::HashSet; + + if elements.is_empty() { + return Some(self.genv.new_source(Type::array())); + } + + let mut element_types: HashSet = HashSet::new(); + + for element in &elements { + if let Some(vtx) = self.install_node(element) { + if let Some(source) = self.genv.get_source(vtx) { + element_types.insert(source.ty.clone()); + } else if let Some(vertex) = self.genv.get_vertex(vtx) { + for ty in vertex.types.keys() { + element_types.insert(ty.clone()); + } + } + } + } + + let array_type = if element_types.is_empty() { + Type::array() + } else if element_types.len() == 1 { + let elem_type = element_types.into_iter().next().unwrap(); + Type::array_of(elem_type) + } else { + let types_vec: Vec = element_types.into_iter().collect(); + let union_type = Type::Union(types_vec); + Type::array_of(union_type) + }; + + Some(self.genv.new_source(array_type)) + } + /// Process nodes that need child evaluation first fn process_needs_child(&mut self, kind: NeedsChildKind) -> Option { match kind { @@ -83,8 +146,32 @@ impl<'a> AstInstaller<'a> { receiver, method_name, location, + block, } => { let recv_vtx = self.install_node(&receiver)?; + + // Process block if present (e.g., `x.each { |i| ... }`) + // Collect block parameter vertex IDs for type inference + let mut block_param_vtxs: Vec = Vec::new(); + if let Some(block_node) = block { + // Block may be a BlockNode or BlockArgumentNode + if let Some(bn) = block_node.as_block_node() { + block_param_vtxs = self.install_block_node_with_params(&bn); + } + } + + // Create BlockParameterTypeBox if block has parameters + if !block_param_vtxs.is_empty() { + let box_id = self.genv.alloc_box_id(); + let block_box = BlockParameterTypeBox::new( + box_id, + recv_vtx, + method_name.clone(), + block_param_vtxs, + ); + self.genv.register_box(box_id, Box::new(block_box)); + } + Some(finish_method_call( self.genv, recv_vtx, @@ -131,6 +218,120 @@ impl<'a> AstInstaller<'a> { None } + /// Install block node + /// + /// Processes blocks like `{ |x| x.to_s }` or `do |item| item.upcase end` + fn install_block_node(&mut self, block_node: &ruby_prism::BlockNode) -> Option { + // Use the version that collects param vtxs, but discard them + self.install_block_node_with_params(block_node); + None + } + + /// Install block node and return block parameter vertex IDs + /// + /// This is used when processing method calls with blocks to collect + /// the block parameter vertices for type inference via BlockParameterTypeBox. + fn install_block_node_with_params( + &mut self, + block_node: &ruby_prism::BlockNode, + ) -> Vec { + // Enter block scope + enter_block_scope(self.genv); + + let mut param_vtxs = Vec::new(); + + // Process block parameters BEFORE processing body + // block_node.parameters() returns Option, need to convert to BlockParametersNode + if let Some(params_node) = block_node.parameters() { + if let Some(block_params) = params_node.as_block_parameters_node() { + param_vtxs = self.install_block_parameters_with_vtxs(&block_params); + } + } + + // Process block body + if let Some(body) = block_node.body() { + if let Some(statements) = body.as_statements_node() { + self.install_statements(&statements); + } else { + // Single expression body + self.install_node(&body); + } + } + + // Exit block scope + exit_block_scope(self.genv); + + param_vtxs + } + + /// Install block parameters as local variables + /// + /// Block parameters like `|x, y|` are registered as local variables + /// with Bot (untyped) type. + #[allow(dead_code)] + fn install_block_parameters(&mut self, block_params: &ruby_prism::BlockParametersNode) { + // Just call the version that returns vtxs and discard the result + self.install_block_parameters_with_vtxs(block_params); + } + + /// Install block parameters and return their vertex IDs + /// + /// This version is used when we need to track the block parameter vertices + /// for type inference from the method's RBS block signature. + fn install_block_parameters_with_vtxs( + &mut self, + block_params: &ruby_prism::BlockParametersNode, + ) -> Vec { + let mut vtxs = Vec::new(); + + // BlockParametersNode contains a ParametersNode + if let Some(params) = block_params.parameters() { + // Process required parameters (most common in blocks) + for node in params.requireds().iter() { + if let Some(req_param) = node.as_required_parameter_node() { + let name = String::from_utf8_lossy(req_param.name().as_slice()).to_string(); + let vtx = install_block_parameter(self.genv, self.lenv, name); + vtxs.push(vtx); + } + } + + // Optional parameters in blocks: { |x = 1| ... } + for node in params.optionals().iter() { + if let Some(opt_param) = node.as_optional_parameter_node() { + let name = String::from_utf8_lossy(opt_param.name().as_slice()).to_string(); + let default_value = opt_param.value(); + + if let Some(default_vtx) = self.install_node(&default_value) { + let vtx = install_optional_parameter( + self.genv, + self.lenv, + &mut self.changes, + name, + default_vtx, + ); + vtxs.push(vtx); + } else { + let vtx = install_block_parameter(self.genv, self.lenv, name); + vtxs.push(vtx); + } + } + } + + // Rest parameter in blocks: { |*args| ... } + if let Some(rest_node) = params.rest() { + if let Some(rest_param) = rest_node.as_rest_parameter_node() { + if let Some(name_id) = rest_param.name() { + let name = String::from_utf8_lossy(name_id.as_slice()).to_string(); + let vtx = install_rest_parameter(self.genv, self.lenv, name); + vtxs.push(vtx); + } + } + } + } + + vtxs + } + /// Install method parameters as local variables fn install_parameters(&mut self, params_node: &ruby_prism::ParametersNode) { // Required parameters: def foo(a, b) diff --git a/rust/src/analyzer/literals.rs b/rust/src/analyzer/literals.rs index 42f95a2..d4bdd72 100644 --- a/rust/src/analyzer/literals.rs +++ b/rust/src/analyzer/literals.rs @@ -1,9 +1,11 @@ //! Literal Handlers - Processing Ruby literal values //! //! This module is responsible for: -//! - String, Integer, Array, Hash literals +//! - String, Integer, Hash literals //! - nil, true, false, Symbol literals //! - Creating Source vertices with fixed types +//! +//! Note: Array literals are handled in install.rs for element type inference use crate::env::GlobalEnv; use crate::graph::VertexId; @@ -11,6 +13,9 @@ use crate::types::Type; use ruby_prism::Node; /// Install literal nodes and return their VertexId +/// +/// Note: Array literals are NOT handled here because they require +/// child processing for element type inference. See install.rs. pub fn install_literal(genv: &mut GlobalEnv, node: &Node) -> Option { // "hello" if node.as_string_node().is_some() { @@ -22,11 +27,6 @@ pub fn install_literal(genv: &mut GlobalEnv, node: &Node) -> Option { return Some(genv.new_source(Type::integer())); } - // [1, 2, 3] - if node.as_array_node().is_some() { - return Some(genv.new_source(Type::array())); - } - // {a: 1} if node.as_hash_node().is_some() { return Some(genv.new_source(Type::hash())); diff --git a/rust/src/analyzer/mod.rs b/rust/src/analyzer/mod.rs index e62eb0b..9fff89d 100644 --- a/rust/src/analyzer/mod.rs +++ b/rust/src/analyzer/mod.rs @@ -1,3 +1,4 @@ +mod blocks; mod calls; mod definitions; mod dispatch; diff --git a/rust/src/analyzer/tests/integration_test.rs b/rust/src/analyzer/tests/integration_test.rs index 112e93a..770e796 100644 --- a/rust/src/analyzer/tests/integration_test.rs +++ b/rust/src/analyzer/tests/integration_test.rs @@ -21,6 +21,11 @@ fn analyze(source: &str) -> (GlobalEnv, LocalEnv) { genv.register_builtin_method(Type::string(), "upcase", Type::string()); genv.register_builtin_method(Type::string(), "downcase", Type::string()); + // Register iterator methods for block tests + genv.register_builtin_method(Type::array(), "each", Type::array()); + genv.register_builtin_method(Type::array(), "map", Type::array()); + genv.register_builtin_method(Type::hash(), "each", Type::hash()); + let mut lenv = LocalEnv::new(); let mut installer = AstInstaller::new(&mut genv, &mut lenv, source); @@ -296,3 +301,107 @@ end // No type errors - optional.upcase should work (String has upcase) assert_eq!(genv.type_errors.len(), 0); } + +// ============================================ +// Block Tests +// ============================================ + +#[test] +fn test_block_parameter_available_as_local_var() { + let source = r#" +x = [1, 2, 3] +x.each { |item| y = item } +"#; + + let (genv, _lenv) = analyze(source); + + // No type errors should occur - item parameter should be available in block + assert_eq!(genv.type_errors.len(), 0); +} + +#[test] +fn test_block_with_multiple_parameters() { + let source = r#" +x = { a: 1, b: 2 } +x.each { |key, value| a = key; b = value } +"#; + + let (genv, _lenv) = analyze(source); + + // No type errors should occur - both parameters should be available + assert_eq!(genv.type_errors.len(), 0); +} + +#[test] +fn test_block_do_end_syntax() { + let source = r#" +x = [1, 2, 3] +x.map do |item| + y = item +end +"#; + + let (genv, _lenv) = analyze(source); + + // No type errors should occur - do...end blocks work the same as { } + assert_eq!(genv.type_errors.len(), 0); +} + +#[test] +fn test_block_accesses_outer_scope_variable() { + let source = r#" +outer = "hello" +x = [1, 2, 3] +x.each { |item| y = outer.upcase } +"#; + + let (genv, _lenv) = analyze(source); + + // No type errors - block can access outer scope variable + assert_eq!(genv.type_errors.len(), 0); +} + +#[test] +fn test_nested_blocks() { + let source = r#" +x = [[1, 2], [3, 4]] +x.each { |row| row.each { |item| y = item } } +"#; + + let (genv, _lenv) = analyze(source); + + // No type errors should occur - nested blocks work correctly + assert_eq!(genv.type_errors.len(), 0); +} + +#[test] +fn test_block_in_method_definition() { + let source = r#" +def process_items + items = [1, 2, 3] + items.each { |item| x = item } +end +"#; + + let (genv, _lenv) = analyze(source); + + // No type errors should occur - blocks work inside methods + assert_eq!(genv.type_errors.len(), 0); +} + +#[test] +fn test_block_in_class_method() { + let source = r#" +class Processor + def process + items = [1, 2, 3] + items.map { |item| item } + end +end +"#; + + let (genv, _lenv) = analyze(source); + + // No type errors should occur + assert_eq!(genv.type_errors.len(), 0); +} diff --git a/rust/src/cache/rbs_cache.rs b/rust/src/cache/rbs_cache.rs index 390f137..b95a0b8 100644 --- a/rust/src/cache/rbs_cache.rs +++ b/rust/src/cache/rbs_cache.rs @@ -26,6 +26,8 @@ pub struct SerializableMethodInfo { pub receiver_class: String, pub method_name: String, pub return_type_str: String, // Simplified: store as string + #[serde(default)] + pub block_param_types: Option>, } impl SerializableMethodInfo { @@ -122,6 +124,7 @@ impl RbsCache { receiver_class: m.receiver_class.clone(), method_name: m.method_name.clone(), return_type: crate::rbs::converter::RbsTypeConverter::parse(&m.return_type_str), + block_param_types: m.block_param_types.clone(), }) .collect() } @@ -139,6 +142,7 @@ impl RbsCache { receiver_class: m.receiver_class, method_name: m.method_name, return_type_str: m.return_type.show(), + block_param_types: m.block_param_types, }) .collect(); @@ -165,6 +169,7 @@ mod tests { receiver_class: "String".to_string(), method_name: "upcase".to_string(), return_type_str: "String".to_string(), + block_param_types: None, }], timestamp: SystemTime::now(), }; @@ -196,6 +201,7 @@ mod tests { receiver_class: "String".to_string(), method_name: "upcase".to_string(), return_type_str: "String".to_string(), + block_param_types: None, }; let return_type = method_info.return_type(); @@ -212,11 +218,13 @@ mod tests { receiver_class: "String".to_string(), method_name: "upcase".to_string(), return_type_str: "String".to_string(), + block_param_types: None, }, SerializableMethodInfo { receiver_class: "Integer".to_string(), method_name: "to_s".to_string(), return_type_str: "String".to_string(), + block_param_types: None, }, ], timestamp: SystemTime::now(), @@ -243,11 +251,13 @@ mod tests { receiver_class: "String".to_string(), method_name: "upcase".to_string(), return_type_str: "String".to_string(), + block_param_types: None, }, SerializableMethodInfo { receiver_class: "Array".to_string(), method_name: "first".to_string(), return_type_str: "Object".to_string(), + block_param_types: None, }, ], timestamp: SystemTime::now(), diff --git a/rust/src/checker.rs b/rust/src/checker.rs index 8ddd455..df54967 100644 --- a/rust/src/checker.rs +++ b/rust/src/checker.rs @@ -62,6 +62,7 @@ impl FileChecker { /// Load RBS methods from cache (CLI mode without Ruby runtime) fn load_rbs_from_cache(genv: &mut GlobalEnv) -> Result<()> { use crate::cache::RbsCache; + use crate::rbs::converter::RbsTypeConverter; use crate::types::Type; let cache = RbsCache::load().context( @@ -75,10 +76,20 @@ fn load_rbs_from_cache(genv: &mut GlobalEnv) -> Result<()> { let receiver_type = Type::Instance { class_name: method_info.receiver_class.clone(), }; - genv.register_builtin_method( + + // Convert block param type strings to Type enums + let block_param_types = method_info.block_param_types.as_ref().map(|types| { + types + .iter() + .map(|s| RbsTypeConverter::parse(s)) + .collect() + }); + + genv.register_builtin_method_with_block( receiver_type, &method_info.method_name, method_info.return_type(), + block_param_types, ); } diff --git a/rust/src/env/global_env.rs b/rust/src/env/global_env.rs index 3d01219..d397fde 100644 --- a/rust/src/env/global_env.rs +++ b/rust/src/env/global_env.rs @@ -115,6 +115,11 @@ impl GlobalEnv { } } } + + // Reschedule boxes that need to run again + for box_id in changes.take_reschedule_boxes() { + self.box_manager.add_run(box_id); + } } /// Execute all Boxes @@ -145,6 +150,18 @@ impl GlobalEnv { self.method_registry.register(recv_ty, method_name, ret_ty); } + /// Register built-in method with block parameter types + pub fn register_builtin_method_with_block( + &mut self, + recv_ty: Type, + method_name: &str, + ret_ty: Type, + block_param_types: Option>, + ) { + self.method_registry + .register_with_block(recv_ty, method_name, ret_ty, block_param_types); + } + // ===== Type Errors ===== /// Record a type error (undefined method) diff --git a/rust/src/env/method_registry.rs b/rust/src/env/method_registry.rs index 3c2bdbc..cd2cacd 100644 --- a/rust/src/env/method_registry.rs +++ b/rust/src/env/method_registry.rs @@ -7,6 +7,7 @@ use std::collections::HashMap; #[derive(Debug, Clone)] pub struct MethodInfo { pub return_type: Type, + pub block_param_types: Option>, } /// Registry for method definitions @@ -25,18 +26,48 @@ impl MethodRegistry { /// Register a method for a receiver type pub fn register(&mut self, recv_ty: Type, method_name: &str, ret_ty: Type) { + self.register_with_block(recv_ty, method_name, ret_ty, None); + } + + /// Register a method with block parameter types + pub fn register_with_block( + &mut self, + recv_ty: Type, + method_name: &str, + ret_ty: Type, + block_param_types: Option>, + ) { self.methods.insert( (recv_ty, method_name.to_string()), MethodInfo { return_type: ret_ty, + block_param_types, }, ); } /// Resolve a method for a receiver type + /// + /// For generic types like `Array[Integer]`, first tries exact match, + /// then falls back to base class match (`Array`). pub fn resolve(&self, recv_ty: &Type, method_name: &str) -> Option<&MethodInfo> { - self.methods + // First, try exact match + if let Some(info) = self + .methods .get(&(recv_ty.clone(), method_name.to_string())) + { + return Some(info); + } + + // For generic types, fall back to base class + if let Type::Generic { class_name, .. } = recv_ty { + let base_type = Type::Instance { + class_name: class_name.clone(), + }; + return self.methods.get(&(base_type, method_name.to_string())); + } + + None } } diff --git a/rust/src/env/mod.rs b/rust/src/env/mod.rs index 5ed9c61..45d5ee1 100644 --- a/rust/src/env/mod.rs +++ b/rust/src/env/mod.rs @@ -13,3 +13,4 @@ pub mod vertex_manager; pub use global_env::GlobalEnv; pub use local_env::LocalEnv; +pub use scope::ScopeKind; diff --git a/rust/src/graph/box.rs b/rust/src/graph/box.rs index b97e3f2..8459491 100644 --- a/rust/src/graph/box.rs +++ b/rust/src/graph/box.rs @@ -24,8 +24,13 @@ pub struct MethodCallBox { method_name: String, ret: VertexId, location: Option, // Source code location + /// Number of times this box has been rescheduled + reschedule_count: u8, } +/// Maximum number of reschedules before giving up +const MAX_RESCHEDULE_COUNT: u8 = 3; + impl MethodCallBox { pub fn new( id: BoxId, @@ -40,6 +45,7 @@ impl MethodCallBox { method_name, ret, location, + reschedule_count: 0, } } } @@ -66,6 +72,17 @@ impl BoxTrait for MethodCallBox { return; }; + // If receiver has no types yet, reschedule this box for later + // This handles cases like block parameters that are typed later + if recv_types.is_empty() { + if self.reschedule_count < MAX_RESCHEDULE_COUNT { + self.reschedule_count += 1; + changes.reschedule(self.id); + } + // If max reschedules reached, just skip (receiver type is unknown) + return; + } + for recv_ty in recv_types { // Resolve method if let Some(method_info) = genv.resolve_method(&recv_ty, &self.method_name) { @@ -86,6 +103,146 @@ impl BoxTrait for MethodCallBox { } } +/// Box for resolving block parameter types from method call receiver +/// +/// When a method with a block is called (e.g., `str.each_char { |c| ... }`), +/// this box resolves the block parameter types from the method's RBS definition +/// and propagates them to the block parameter vertices. +#[allow(dead_code)] +pub struct BlockParameterTypeBox { + id: BoxId, + /// Receiver vertex of the method call + recv_vtx: VertexId, + /// Method name being called + method_name: String, + /// Block parameter vertices (in order) + block_param_vtxs: Vec, +} + +impl BlockParameterTypeBox { + pub fn new( + id: BoxId, + recv_vtx: VertexId, + method_name: String, + block_param_vtxs: Vec, + ) -> Self { + Self { + id, + recv_vtx, + method_name, + block_param_vtxs, + } + } + + /// Check if a type is a type variable name (e.g., Elem, K, V) + fn is_type_variable_name(name: &str) -> bool { + matches!( + name, + "Elem" | "K" | "V" | "T" | "U" | "A" | "B" | "Element" | "Key" | "Value" | "Out" | "In" + ) + } + + /// Try to resolve a type variable from receiver's type arguments. + /// + /// For `Array[Integer]#each { |x| }`, the block param type is `Elem`. + /// This resolves `Elem` → `Integer` using Array's type argument. + /// + /// Type variable mapping for common generic classes: + /// - Array[Elem]: Elem → type_args[0] + /// - Hash[K, V]: K → type_args[0], V → type_args[1] + fn resolve_type_variable(ty: &Type, recv_ty: &Type) -> Option { + let type_var_name = match ty { + Type::Instance { class_name } if Self::is_type_variable_name(class_name) => class_name, + _ => return None, // Not a type variable + }; + + // Get type arguments from receiver + let type_args = recv_ty.type_args()?; + let class_name = recv_ty.base_class_name()?; + + // Map type variable to type argument index based on class + let index = match (class_name, type_var_name.as_str()) { + // Array[Elem] + ("Array", "Elem") => 0, + ("Array", "T") => 0, + ("Array", "Element") => 0, + // Hash[K, V] + ("Hash", "K") | ("Hash", "Key") => 0, + ("Hash", "V") | ("Hash", "Value") => 1, + // Generic fallback: first type arg for common names + (_, "Elem") | (_, "T") | (_, "Element") => 0, + _ => return None, + }; + + type_args.get(index).cloned() + } +} + +impl BoxTrait for BlockParameterTypeBox { + fn id(&self) -> BoxId { + self.id + } + + fn ret(&self) -> VertexId { + // This box doesn't have a single return value + // Return first param vtx as a placeholder + self.block_param_vtxs + .first() + .copied() + .unwrap_or(VertexId(0)) + } + + fn run(&mut self, genv: &mut GlobalEnv, changes: &mut ChangeSet) { + // Get receiver types + let recv_types: Vec = if let Some(recv_vertex) = genv.get_vertex(self.recv_vtx) { + recv_vertex.types.keys().cloned().collect() + } else if let Some(recv_source) = genv.get_source(self.recv_vtx) { + vec![recv_source.ty.clone()] + } else { + return; + }; + + for recv_ty in recv_types { + // Resolve method to get block parameter types + // Clone the block_param_types to avoid borrow issues + let block_param_types = genv + .resolve_method(&recv_ty, &self.method_name) + .and_then(|info| info.block_param_types.clone()); + + if let Some(param_types) = block_param_types { + // Map block parameter types to vertices + for (i, param_type) in param_types.iter().enumerate() { + if i < self.block_param_vtxs.len() { + let param_vtx = self.block_param_vtxs[i]; + + // Try to resolve type variable from receiver's type arguments + let resolved_type = + if let Some(resolved) = Self::resolve_type_variable(¶m_type, &recv_ty) { + // Type variable resolved (e.g., Elem → Integer) + resolved + } else if let Type::Instance { class_name } = ¶m_type { + if Self::is_type_variable_name(class_name) { + // Type variable couldn't be resolved, skip + continue; + } else { + // Regular type, use as-is + param_type.clone() + } + } else { + // Other type (Union, Generic, etc.), use as-is + param_type.clone() + }; + + // Create source with the resolved type + let src_id = genv.new_source(resolved_type); + changes.add_edge(src_id, param_vtx); + } + } + } + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -154,4 +311,190 @@ mod tests { let ret_vertex = genv.get_vertex(ret_vtx).unwrap(); assert_eq!(ret_vertex.show(), "untyped"); } + + #[test] + fn test_block_param_type_box_simple() { + let mut genv = GlobalEnv::new(); + + // Register String#each_char with block param type String + genv.register_builtin_method_with_block( + Type::string(), + "each_char", + Type::string(), + Some(vec![Type::string()]), + ); + + // Create receiver vertex with String type + let recv_vtx = genv.new_vertex(); + let str_src = genv.new_source(Type::string()); + genv.add_edge(str_src, recv_vtx); + + // Create block parameter vertex + let param_vtx = genv.new_vertex(); + + // Create and run BlockParameterTypeBox + let box_id = genv.alloc_box_id(); + let block_box = BlockParameterTypeBox::new( + box_id, + recv_vtx, + "each_char".to_string(), + vec![param_vtx], + ); + genv.register_box(box_id, Box::new(block_box)); + + // Run all boxes + genv.run_all(); + + // Block parameter should now have String type + assert_eq!(genv.get_vertex(param_vtx).unwrap().show(), "String"); + } + + #[test] + fn test_block_param_type_variable_skipped() { + let mut genv = GlobalEnv::new(); + + // Register Array#each with block param type Elem (type variable) + genv.register_builtin_method_with_block( + Type::array(), + "each", + Type::array(), + Some(vec![Type::Instance { + class_name: "Elem".to_string(), + }]), + ); + + let recv_vtx = genv.new_vertex(); + let arr_src = genv.new_source(Type::array()); + genv.add_edge(arr_src, recv_vtx); + + let param_vtx = genv.new_vertex(); + + let box_id = genv.alloc_box_id(); + let block_box = BlockParameterTypeBox::new( + box_id, + recv_vtx, + "each".to_string(), + vec![param_vtx], + ); + genv.register_box(box_id, Box::new(block_box)); + + genv.run_all(); + + // Block parameter should remain untyped (type variable skipped) + assert_eq!(genv.get_vertex(param_vtx).unwrap().show(), "untyped"); + } + + #[test] + fn test_block_param_multiple_params() { + let mut genv = GlobalEnv::new(); + + // Register a method with multiple block params + genv.register_builtin_method_with_block( + Type::string(), + "each_with_index", + Type::string(), + Some(vec![Type::string(), Type::integer()]), + ); + + let recv_vtx = genv.new_vertex(); + let str_src = genv.new_source(Type::string()); + genv.add_edge(str_src, recv_vtx); + + let param1_vtx = genv.new_vertex(); + let param2_vtx = genv.new_vertex(); + + let box_id = genv.alloc_box_id(); + let block_box = BlockParameterTypeBox::new( + box_id, + recv_vtx, + "each_with_index".to_string(), + vec![param1_vtx, param2_vtx], + ); + genv.register_box(box_id, Box::new(block_box)); + + genv.run_all(); + + // Both params should have their types + assert_eq!(genv.get_vertex(param1_vtx).unwrap().show(), "String"); + assert_eq!(genv.get_vertex(param2_vtx).unwrap().show(), "Integer"); + } + + #[test] + fn test_block_param_type_variable_resolved() { + let mut genv = GlobalEnv::new(); + + // Register Array#each with block param type Elem (type variable) + genv.register_builtin_method_with_block( + Type::array(), + "each", + Type::array(), + Some(vec![Type::Instance { + class_name: "Elem".to_string(), + }]), + ); + + // Create receiver vertex with Array[Integer] type + let recv_vtx = genv.new_vertex(); + let arr_src = genv.new_source(Type::array_of(Type::integer())); + genv.add_edge(arr_src, recv_vtx); + + let param_vtx = genv.new_vertex(); + + let box_id = genv.alloc_box_id(); + let block_box = BlockParameterTypeBox::new( + box_id, + recv_vtx, + "each".to_string(), + vec![param_vtx], + ); + genv.register_box(box_id, Box::new(block_box)); + + genv.run_all(); + + // Block parameter should be Integer (resolved from Array[Integer]) + assert_eq!(genv.get_vertex(param_vtx).unwrap().show(), "Integer"); + } + + #[test] + fn test_hash_type_variable_resolved() { + let mut genv = GlobalEnv::new(); + + // Register Hash#each with block param types K, V + genv.register_builtin_method_with_block( + Type::hash(), + "each", + Type::hash(), + Some(vec![ + Type::Instance { + class_name: "K".to_string(), + }, + Type::Instance { + class_name: "V".to_string(), + }, + ]), + ); + + // Create receiver vertex with Hash[String, Integer] type + let recv_vtx = genv.new_vertex(); + let hash_src = genv.new_source(Type::hash_of(Type::string(), Type::integer())); + genv.add_edge(hash_src, recv_vtx); + + let key_vtx = genv.new_vertex(); + let value_vtx = genv.new_vertex(); + + let box_id = genv.alloc_box_id(); + let block_box = BlockParameterTypeBox::new( + box_id, + recv_vtx, + "each".to_string(), + vec![key_vtx, value_vtx], + ); + genv.register_box(box_id, Box::new(block_box)); + + genv.run_all(); + + // Block parameters should be resolved from Hash[String, Integer] + assert_eq!(genv.get_vertex(key_vtx).unwrap().show(), "String"); + assert_eq!(genv.get_vertex(value_vtx).unwrap().show(), "Integer"); + } } diff --git a/rust/src/graph/change_set.rs b/rust/src/graph/change_set.rs index 1f78fac..178fa4f 100644 --- a/rust/src/graph/change_set.rs +++ b/rust/src/graph/change_set.rs @@ -1,3 +1,4 @@ +use super::r#box::BoxId; use super::VertexId; /// Manages edge changes for type propagation @@ -5,6 +6,8 @@ use super::VertexId; pub struct ChangeSet { new_edges: Vec<(VertexId, VertexId)>, edges: Vec<(VertexId, VertexId)>, + /// Boxes to reschedule for later execution + reschedule_boxes: Vec, } impl ChangeSet { @@ -12,6 +15,7 @@ impl ChangeSet { Self { new_edges: Vec::new(), edges: Vec::new(), + reschedule_boxes: Vec::new(), } } @@ -20,6 +24,16 @@ impl ChangeSet { self.new_edges.push((src, dst)); } + /// Request to reschedule a Box for later execution + pub fn reschedule(&mut self, box_id: BoxId) { + self.reschedule_boxes.push(box_id); + } + + /// Get and clear boxes that need to be rescheduled + pub fn take_reschedule_boxes(&mut self) -> Vec { + std::mem::take(&mut self.reschedule_boxes) + } + /// Commit changes and return list of added/removed edges pub fn reinstall(&mut self) -> Vec { // Remove duplicates diff --git a/rust/src/graph/mod.rs b/rust/src/graph/mod.rs index bd03d26..75cc966 100644 --- a/rust/src/graph/mod.rs +++ b/rust/src/graph/mod.rs @@ -3,5 +3,5 @@ pub mod change_set; pub mod vertex; pub use change_set::{ChangeSet, EdgeUpdate}; -pub use r#box::{BoxId, BoxTrait, MethodCallBox}; +pub use r#box::{BlockParameterTypeBox, BoxId, BoxTrait, MethodCallBox}; pub use vertex::{Source, Vertex, VertexId}; diff --git a/rust/src/lib.rs b/rust/src/lib.rs index e03fd47..20f6e8d 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -11,7 +11,8 @@ pub mod parser; pub mod source_map; pub mod types; -#[cfg(feature = "ruby-ffi")] +// rbs module is always available (converter has no Ruby FFI dependency) +// but loader and error require ruby-ffi feature pub mod rbs; #[cfg(any(feature = "cli", feature = "lsp"))] diff --git a/rust/src/rbs/loader.rs b/rust/src/rbs/loader.rs index 5b9a7b6..2296f0e 100644 --- a/rust/src/rbs/loader.rs +++ b/rust/src/rbs/loader.rs @@ -2,6 +2,7 @@ use crate::env::GlobalEnv; use crate::rbs::converter::RbsTypeConverter; use crate::rbs::error::RbsError; use crate::types::Type; +use magnus::value::ReprValue; use magnus::{Error, RArray, RHash, Ruby, TryConvert, Value}; /// Method information loaded from RBS @@ -10,6 +11,7 @@ pub struct RbsMethodInfo { pub receiver_class: String, pub method_name: String, pub return_type: Type, + pub block_param_types: Option>, } /// Loader that calls RBS API via magnus to load method information @@ -100,10 +102,33 @@ impl<'a> RbsLoader<'a> { // Convert RBS type string to internal Type enum let return_type = RbsTypeConverter::parse(&return_type_str); + // Parse block_param_types (optional) + let block_param_types: Option> = + if let Some(bpt_value) = hash.get(self.ruby.to_symbol("block_param_types")) { + if bpt_value.is_nil() { + None + } else if let Ok(bpt_array) = RArray::try_convert(bpt_value) { + let types: Vec = bpt_array + .into_iter() + .filter_map(|v| String::try_convert(v).ok()) + .collect(); + if types.is_empty() { + None + } else { + Some(types) + } + } else { + None + } + } else { + None + }; + method_infos.push(RbsMethodInfo { receiver_class, method_name, return_type, + block_param_types, }); } @@ -144,10 +169,18 @@ pub fn register_rbs_methods(genv: &mut GlobalEnv, ruby: &Ruby) -> Result e @@ -47,5 +51,30 @@ def load_methods results end + + private + + # Extract block parameter types from method_type + # Returns nil if no block, or array of type strings + def extract_block_param_types(method_type) + return nil unless method_type.block + + block_func = method_type.block.type + return nil unless block_func.is_a?(::RBS::Types::Function) + + param_types = [] + + # Required positional parameters + block_func.required_positionals.each do |param| + param_types << param.type.to_s + end + + # Optional positional parameters + block_func.optional_positionals.each do |param| + param_types << param.type.to_s + end + + param_types.empty? ? nil : param_types + end end end diff --git a/rust/src/rbs/mod.rs b/rust/src/rbs/mod.rs index b369258..131a764 100644 --- a/rust/src/rbs/mod.rs +++ b/rust/src/rbs/mod.rs @@ -1,14 +1,15 @@ //! RBS type loading and conversion -#[cfg(feature = "ruby-ffi")] +// Converter is always available (no Ruby FFI dependency) pub mod converter; +pub use converter::RbsTypeConverter; + +// These require Ruby FFI for RBS loading #[cfg(feature = "ruby-ffi")] pub mod error; #[cfg(feature = "ruby-ffi")] pub mod loader; -#[cfg(feature = "ruby-ffi")] -pub use converter::RbsTypeConverter; #[cfg(feature = "ruby-ffi")] pub use error::RbsError; #[cfg(feature = "ruby-ffi")] diff --git a/rust/src/types.rs b/rust/src/types.rs index a8f4f0e..01953dd 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -4,6 +4,11 @@ pub enum Type { /// Instance type: String, Integer, etc. Instance { class_name: String }, + /// Generic instance type: Array[Integer], Hash[String, Integer], etc. + Generic { + class_name: String, + type_args: Vec, + }, /// Singleton type: for class methods Singleton { class_name: String }, /// nil type @@ -19,6 +24,13 @@ impl Type { pub fn show(&self) -> String { match self { Type::Instance { class_name } => class_name.clone(), + Type::Generic { + class_name, + type_args, + } => { + let args: Vec<_> = type_args.iter().map(|t| t.show()).collect(); + format!("{}[{}]", class_name, args.join(", ")) + } Type::Singleton { class_name } => format!("singleton({})", class_name), Type::Nil => "nil".to_string(), Type::Union(types) => { @@ -29,6 +41,24 @@ impl Type { } } + /// Get the base class name (without type arguments) + pub fn base_class_name(&self) -> Option<&str> { + match self { + Type::Instance { class_name } => Some(class_name), + Type::Generic { class_name, .. } => Some(class_name), + Type::Singleton { class_name } => Some(class_name), + _ => None, + } + } + + /// Get type arguments for generic types + pub fn type_args(&self) -> Option<&[Type]> { + match self { + Type::Generic { type_args, .. } => Some(type_args), + _ => None, + } + } + /// Convenience constructors pub fn string() -> Self { Type::Instance { @@ -53,6 +83,22 @@ impl Type { class_name: "Hash".to_string(), } } + + /// Create a generic Array type: Array[element_type] + pub fn array_of(element_type: Type) -> Self { + Type::Generic { + class_name: "Array".to_string(), + type_args: vec![element_type], + } + } + + /// Create a generic Hash type: Hash[key_type, value_type] + pub fn hash_of(key_type: Type, value_type: Type) -> Self { + Type::Generic { + class_name: "Hash".to_string(), + type_args: vec![key_type, value_type], + } + } } #[cfg(test)] @@ -72,4 +118,32 @@ mod tests { let union = Type::Union(vec![Type::string(), Type::integer()]); assert_eq!(union.show(), "String | Integer"); } + + #[test] + fn test_generic_type_show() { + let array_int = Type::array_of(Type::integer()); + assert_eq!(array_int.show(), "Array[Integer]"); + + let hash_str_int = Type::hash_of(Type::string(), Type::integer()); + assert_eq!(hash_str_int.show(), "Hash[String, Integer]"); + } + + #[test] + fn test_base_class_name() { + assert_eq!(Type::string().base_class_name(), Some("String")); + assert_eq!(Type::array_of(Type::integer()).base_class_name(), Some("Array")); + assert_eq!(Type::Nil.base_class_name(), None); + assert_eq!(Type::Bot.base_class_name(), None); + } + + #[test] + fn test_type_args() { + let array_int = Type::array_of(Type::integer()); + let args = array_int.type_args().unwrap(); + assert_eq!(args.len(), 1); + assert_eq!(args[0].show(), "Integer"); + + // Non-generic types have no type args + assert!(Type::string().type_args().is_none()); + } } diff --git a/test/blocks_test.rb b/test/blocks_test.rb new file mode 100644 index 0000000..6e2d1f6 --- /dev/null +++ b/test/blocks_test.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +require 'test_helper' + +class BlocksTest < Minitest::Test + def setup + @analyzer = MethodRay::Analyzer.new('test.rb') + end + + # Basic block with single parameter - code parses correctly + def test_block_with_single_parameter + code = <<~RUBY + items = [1, 2, 3] + items.each { |x| x } + RUBY + result = @analyzer.infer_types(code) + # Outer variable should be tracked + assert_includes result, 'items: Array' + end + + # Block with multiple parameters + def test_block_with_multiple_parameters + code = <<~RUBY + data = {a: 1, b: 2} + data.each { |k, v| k } + RUBY + result = @analyzer.infer_types(code) + assert_includes result, 'data: Hash' + end + + # do...end syntax + def test_block_do_end_syntax + code = <<~RUBY + items = [1, 2, 3] + items.each do |item| + item + end + RUBY + result = @analyzer.infer_types(code) + assert_includes result, 'items: Array' + end + + # Block accessing outer scope variable + def test_block_accesses_outer_scope + code = <<~RUBY + prefix = "Item: " + items = [1, 2, 3] + items.each { |x| prefix.upcase } + RUBY + result = @analyzer.infer_types(code) + assert_includes result, 'prefix: String' + assert_includes result, 'items: Array' + end + + # map with block + def test_map_with_block + code = <<~RUBY + items = [1, 2, 3] + items.map { |x| x } + RUBY + result = @analyzer.infer_types(code) + assert_includes result, 'items: Array' + end + + # select with block + def test_select_with_block + code = <<~RUBY + items = [1, 2, 3] + items.select { |x| x } + RUBY + result = @analyzer.infer_types(code) + assert_includes result, 'items: Array' + end + + # Nested blocks + def test_nested_blocks + code = <<~RUBY + outer = [[1, 2], [3, 4]] + outer.each do |arr| + arr.each { |x| x } + end + RUBY + result = @analyzer.infer_types(code) + assert_includes result, 'outer: Array' + end + + # Block inside method definition + def test_block_inside_method + code = <<~RUBY + class Processor + def process + items = [1, 2, 3] + items.each { |item| item } + end + end + RUBY + result = @analyzer.infer_types(code) + # Should parse without error + refute_nil result + end + + # Block with optional parameter and default value + def test_block_with_optional_parameter + code = <<~RUBY + items = [1, 2, 3] + items.each { |x = "default"| x.upcase } + RUBY + result = @analyzer.infer_types(code) + assert_includes result, 'items: Array' + end + + # Block with rest parameter + def test_block_with_rest_parameter + code = <<~RUBY + items = [1, 2, 3] + items.each { |*args| args.first } + RUBY + result = @analyzer.infer_types(code) + assert_includes result, 'items: Array' + end + + # Outer variable modified before block + def test_outer_variable_before_block + code = <<~RUBY + message = "Hello" + items = [1, 2, 3] + items.each { |x| message } + RUBY + result = @analyzer.infer_types(code) + assert_includes result, 'message: String' + assert_includes result, 'items: Array' + end +end