diff --git a/core/src/analyzer/compound_assignments.rs b/core/src/analyzer/compound_assignments.rs new file mode 100644 index 0000000..1d56395 --- /dev/null +++ b/core/src/analyzer/compound_assignments.rs @@ -0,0 +1,194 @@ +//! Compound assignment handlers (`x += 1`, `x ||= val`, `x &&= val`). +//! +//! `||=` and `&&=` use the same Union(existing, val) approximation — a sound +//! over-approximation without condition narrowing. + +use crate::env::{GlobalEnv, LocalEnv}; +use crate::graph::{ChangeSet, VertexId}; +use crate::types::Type; + +use super::calls::install_method_call; +use super::variables::{ + install_class_var_read, install_class_var_write, install_constant_read, install_constant_write, + install_global_var_read, install_global_var_write, install_ivar_read, install_ivar_write, + install_local_var_read, install_local_var_write, +}; + +pub(crate) enum CompoundVarKind { + Local(String), + Ivar(String), + ClassVar(String), + GlobalVar(String), + Constant(String), +} + +impl CompoundVarKind { + fn read(&self, genv: &GlobalEnv, lenv: &LocalEnv) -> Option { + match self { + Self::Local(name) => install_local_var_read(lenv, name), + Self::Ivar(name) => install_ivar_read(genv, name), + Self::ClassVar(name) => install_class_var_read(genv, name), + Self::GlobalVar(name) => install_global_var_read(genv, name), + Self::Constant(name) => install_constant_read(genv, name), + } + } + + fn write( + self, + genv: &mut GlobalEnv, + lenv: &mut LocalEnv, + changes: &mut ChangeSet, + value_vtx: VertexId, + ) -> VertexId { + match self { + Self::Local(name) => install_local_var_write(genv, lenv, changes, name, value_vtx), + Self::Ivar(name) => install_ivar_write(genv, name, value_vtx), + Self::ClassVar(name) => install_class_var_write(genv, name, value_vtx), + Self::GlobalVar(name) => install_global_var_write(genv, name, value_vtx), + Self::Constant(name) => install_constant_write(genv, name, value_vtx), + } + } +} + +pub(crate) enum CompoundOp { + Operator(String), + Logical, +} + +pub(crate) fn process_compound_write( + genv: &mut GlobalEnv, + lenv: &mut LocalEnv, + changes: &mut ChangeSet, + var_kind: CompoundVarKind, + op: CompoundOp, + value_vtx: VertexId, +) -> VertexId { + match op { + CompoundOp::Operator(operator) => { + let current_vtx = var_kind.read(genv, lenv) + .unwrap_or_else(|| genv.new_source(Type::Nil)); + let result_vtx = install_method_call( + genv, current_vtx, operator, vec![value_vtx], None, None, false, + ); + var_kind.write(genv, lenv, changes, result_vtx) + } + CompoundOp::Logical => { + let merge_vtx = genv.new_vertex(); + if let Some(current_vtx) = var_kind.read(genv, lenv) { + genv.add_edge(current_vtx, merge_vtx); + } + genv.add_edge(value_vtx, merge_vtx); + var_kind.write(genv, lenv, changes, merge_vtx) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_operator_write() { + let mut genv = GlobalEnv::new(); + let mut lenv = LocalEnv::new(); + let mut changes = ChangeSet::new(); + + let int_vtx = genv.new_source(Type::integer()); + install_local_var_write(&mut genv, &mut lenv, &mut changes, "x".to_string(), int_vtx); + + let rhs_vtx = genv.new_source(Type::integer()); + let result = process_compound_write( + &mut genv, &mut lenv, &mut changes, + CompoundVarKind::Local("x".to_string()), + CompoundOp::Operator("+".to_string()), + rhs_vtx, + ); + + assert_ne!(result, int_vtx); + assert_eq!(install_local_var_read(&lenv, "x"), Some(result)); + } + + #[test] + fn test_operator_write_uninitialized() { + let mut genv = GlobalEnv::new(); + let mut lenv = LocalEnv::new(); + let mut changes = ChangeSet::new(); + + let rhs_vtx = genv.new_source(Type::integer()); + let result = process_compound_write( + &mut genv, &mut lenv, &mut changes, + CompoundVarKind::Local("x".to_string()), + CompoundOp::Operator("+".to_string()), + rhs_vtx, + ); + + assert_eq!(install_local_var_read(&lenv, "x"), Some(result)); + } + + #[test] + fn test_logical_write_produces_union() { + let mut genv = GlobalEnv::new(); + let mut lenv = LocalEnv::new(); + let mut changes = ChangeSet::new(); + + let str_vtx = genv.new_source(Type::string()); + install_local_var_write(&mut genv, &mut lenv, &mut changes, "x".to_string(), str_vtx); + + let int_vtx = genv.new_source(Type::integer()); + let result = process_compound_write( + &mut genv, &mut lenv, &mut changes, + CompoundVarKind::Local("x".to_string()), + CompoundOp::Logical, + int_vtx, + ); + + assert_eq!(install_local_var_read(&lenv, "x"), Some(result)); + genv.apply_changes(changes); + genv.run_all(); + let types = genv.get_receiver_types(result).unwrap(); + assert!(types.contains(&Type::string())); + assert!(types.contains(&Type::integer())); + } + + #[test] + fn test_logical_write_uninitialized() { + let mut genv = GlobalEnv::new(); + let mut lenv = LocalEnv::new(); + let mut changes = ChangeSet::new(); + + let str_vtx = genv.new_source(Type::string()); + let result = process_compound_write( + &mut genv, &mut lenv, &mut changes, + CompoundVarKind::Local("x".to_string()), + CompoundOp::Logical, + str_vtx, + ); + + genv.apply_changes(changes); + genv.run_all(); + let types = genv.get_receiver_types(result).unwrap(); + assert!(types.contains(&Type::string())); + } + + #[test] + fn test_ivar_compound_write() { + let mut genv = GlobalEnv::new(); + let mut lenv = LocalEnv::new(); + let mut changes = ChangeSet::new(); + genv.enter_class("TestClass".to_string(), None); + genv.enter_method("test_method".to_string()); + + let int_vtx = genv.new_source(Type::integer()); + install_ivar_write(&mut genv, "@count".to_string(), int_vtx); + + let rhs_vtx = genv.new_source(Type::integer()); + let result = process_compound_write( + &mut genv, &mut lenv, &mut changes, + CompoundVarKind::Ivar("@count".to_string()), + CompoundOp::Operator("+".to_string()), + rhs_vtx, + ); + + assert_eq!(result, int_vtx); + } +} diff --git a/core/src/analyzer/dispatch.rs b/core/src/analyzer/dispatch.rs index c2c381f..fa95d61 100644 --- a/core/src/analyzer/dispatch.rs +++ b/core/src/analyzer/dispatch.rs @@ -13,6 +13,7 @@ use ruby_prism::Node; use super::bytes_to_name; use super::calls::install_method_call; +use super::compound_assignments::{CompoundOp, CompoundVarKind}; use super::variables::{ install_class_var_read, install_class_var_write, install_constant_read, install_constant_write, install_global_var_read, install_global_var_write, install_ivar_read, install_ivar_write, @@ -85,6 +86,11 @@ pub(crate) enum NeedsChildKind<'a> { /// Global variable write: need to process value, then call install_global_var_write GlobalVarWrite { gvar_name: String, value: Node<'a> }, ConstantWrite { const_name: String, value: Node<'a> }, + CompoundWrite { + var_kind: CompoundVarKind, + op: CompoundOp, + value: Node<'a>, + }, /// Proc/lambda literals ProcLiteral { block: Node<'a> }, /// Method call: need to process receiver, then call finish_method_call @@ -281,6 +287,46 @@ pub(crate) fn dispatch_needs_child<'a>(node: &Node<'a>, source: &str) -> Option< }); } + macro_rules! dispatch_compound { + ($node:expr, $as_method:ident, $var_kind:ident, operator) => { + if let Some(n) = $node.$as_method() { + return Some(NeedsChildKind::CompoundWrite { + var_kind: CompoundVarKind::$var_kind(bytes_to_name(n.name().as_slice())), + op: CompoundOp::Operator(bytes_to_name(n.binary_operator().as_slice())), + value: n.value(), + }); + } + }; + ($node:expr, $as_method:ident, $var_kind:ident, $op:ident) => { + if let Some(n) = $node.$as_method() { + return Some(NeedsChildKind::CompoundWrite { + var_kind: CompoundVarKind::$var_kind(bytes_to_name(n.name().as_slice())), + op: CompoundOp::$op, + value: n.value(), + }); + } + }; + } + + macro_rules! dispatch_compound_all { + ($node:expr, $op_method:ident, $or_method:ident, $and_method:ident, $var_kind:ident) => { + dispatch_compound!($node, $op_method, $var_kind, operator); + dispatch_compound!($node, $or_method, $var_kind, Logical); + dispatch_compound!($node, $and_method, $var_kind, Logical); + }; + } + + dispatch_compound_all!(node, + as_local_variable_operator_write_node, as_local_variable_or_write_node, as_local_variable_and_write_node, Local); + dispatch_compound_all!(node, + as_instance_variable_operator_write_node, as_instance_variable_or_write_node, as_instance_variable_and_write_node, Ivar); + dispatch_compound_all!(node, + as_class_variable_operator_write_node, as_class_variable_or_write_node, as_class_variable_and_write_node, ClassVar); + dispatch_compound_all!(node, + as_global_variable_operator_write_node, as_global_variable_or_write_node, as_global_variable_and_write_node, GlobalVar); + dispatch_compound_all!(node, + as_constant_operator_write_node, as_constant_or_write_node, as_constant_and_write_node, Constant); + // Method call: x.upcase, x.each { |i| ... }, or name (implicit self) if let Some(call_node) = node.as_call_node() { let method_name = bytes_to_name(call_node.name().as_slice()); @@ -403,6 +449,12 @@ pub(crate) fn process_needs_child( let value_vtx = super::install::install_node(genv, lenv, changes, source, &value)?; Some(install_local_var_write(genv, lenv, changes, var_name, value_vtx)) } + NeedsChildKind::CompoundWrite { var_kind, op, value } => { + let value_vtx = super::install::install_node(genv, lenv, changes, source, &value)?; + Some(super::compound_assignments::process_compound_write( + genv, lenv, changes, var_kind, op, value_vtx, + )) + } NeedsChildKind::ProcLiteral { block } => { if let Some(block_node) = block.as_block_node() { super::lambdas::process_block_as_proc(genv, lenv, changes, source, &block_node) diff --git a/core/src/analyzer/mod.rs b/core/src/analyzer/mod.rs index ff8a925..d2858d5 100644 --- a/core/src/analyzer/mod.rs +++ b/core/src/analyzer/mod.rs @@ -2,6 +2,7 @@ mod assignments; mod attributes; mod blocks; mod calls; +mod compound_assignments; mod conditionals; mod definitions; mod exceptions; diff --git a/test/compound_assignment_test.rb b/test/compound_assignment_test.rb new file mode 100644 index 0000000..ed51ffa --- /dev/null +++ b/test/compound_assignment_test.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +require 'test_helper' + +class CompoundAssignmentTest < Minitest::Test + include CLITestHelper + + # ============================================ + # No Error + # ============================================ + + def test_local_operator_assign_no_error + source = <<~RUBY + class Foo + def bar + x = 1 + x += 2 + x.even? + end + end + RUBY + + assert_no_check_errors(source) + end + + def test_local_or_assign_no_error + source = <<~RUBY + class Foo + def bar + x = "hello" + x ||= "world" + x.upcase + end + end + RUBY + + assert_no_check_errors(source) + end + + def test_local_or_assign_no_error_without_initialization + source = <<~RUBY + class Foo + def bar + x ||= "world" + x.upcase + end + end + RUBY + + assert_no_check_errors(source) + end + + def test_local_and_assign_no_error + source = <<~RUBY + class Foo + def bar + x = "hello" + x &&= "world" + x.upcase + end + end + RUBY + + assert_no_check_errors(source) + end + + def test_ivar_operator_assign_no_error + source = <<~RUBY + class Counter + def initialize + @count = 0 + end + + def increment + @count += 1 + end + + def value + @count.even? + end + end + RUBY + + assert_no_check_errors(source) + end + + def test_ivar_or_assign_no_error + source = <<~RUBY + class Config + def initialize + @name = "default" + end + + def set_name + @name ||= "fallback" + end + + def display + @name.upcase + end + end + RUBY + + assert_no_check_errors(source) + end + + # ============================================ + # Error Detection + # ============================================ + + def test_operator_assign_type_error + source = <<~RUBY + class Foo + def bar + x = 1 + x += 2 + x.upcase + end + end + RUBY + + assert_check_error(source, method_name: 'upcase', receiver_type: 'Integer') + end + + def test_or_assign_type_error + source = <<~RUBY + class Foo + def bar + x = 1 + x ||= "hello" + x.even? + end + end + RUBY + + assert_check_error(source, method_name: 'even?', receiver_type: 'String') + end + + def test_and_assign_type_error + source = <<~RUBY + class Foo + def bar + x &&= "hello" + x.even? + end + end + RUBY + + assert_check_error(source, method_name: 'even?', receiver_type: 'String') + end + + def test_ivar_operator_assign_type_error + source = <<~RUBY + class Counter + def initialize + @count = 0 + end + + def increment + @count += 1 + end + + def name + @count.upcase + end + end + RUBY + + assert_check_error(source, method_name: 'upcase', receiver_type: 'Integer') + end +end