From 1e02eae05a227db8f67c1c7ff5bae626908a178e Mon Sep 17 00:00:00 2001 From: dak2 Date: Mon, 16 Mar 2026 00:05:17 +0900 Subject: [PATCH] Add safe navigation operator (`&.`) support Implement Ruby's safe navigation operator semantics in the type checker: - Add `safe_navigation` flag to NeedsChildKind::MethodCall, MethodCallContext, and MethodCallBox, detected via ruby-prism's `is_safe_navigation()` - Skip nil receiver in process_recv_type() to suppress false "undefined method for NilClass" errors when using `&.` - Add nil to return type in run() only when receiver types actually include nil, preserving type precision for non-nilable receivers (e.g. `str&.upcase` stays `String`, not `String | nil`) This eliminates false positives on `&.` calls, which are pervasive in Rails and modern Ruby codebases. Co-Authored-By: Claude Opus 4.6 (1M context) --- core/src/analyzer/calls.rs | 7 +-- core/src/analyzer/dispatch.rs | 93 ++++++++++++++++++++++++++++++-- core/src/analyzer/super_calls.rs | 1 + core/src/graph/box.rs | 83 +++++++++++++++++++++++++++- 4 files changed, 176 insertions(+), 8 deletions(-) diff --git a/core/src/analyzer/calls.rs b/core/src/analyzer/calls.rs index 242a04f..037c74b 100644 --- a/core/src/analyzer/calls.rs +++ b/core/src/analyzer/calls.rs @@ -19,6 +19,7 @@ pub fn install_method_call( arg_vtxs: Vec, kwarg_vtxs: Option>, location: Option, + safe_navigation: bool, ) -> VertexId { // Create Vertex for return value let ret_vtx = genv.new_vertex(); @@ -26,7 +27,7 @@ pub fn install_method_call( // Create MethodCallBox with location and argument vertices let box_id = genv.alloc_box_id(); let call_box = - MethodCallBox::new(box_id, recv_vtx, method_name, ret_vtx, arg_vtxs, kwarg_vtxs, location); + MethodCallBox::new(box_id, recv_vtx, method_name, ret_vtx, arg_vtxs, kwarg_vtxs, location, safe_navigation); genv.register_box(box_id, Box::new(call_box)); ret_vtx @@ -43,7 +44,7 @@ mod tests { let recv_vtx = genv.new_source(Type::string()); let ret_vtx = - install_method_call(&mut genv, recv_vtx, "upcase".to_string(), vec![], None, None); + install_method_call(&mut genv, recv_vtx, "upcase".to_string(), vec![], None, None, false); // Return vertex should exist assert!(genv.get_vertex(ret_vtx).is_some()); @@ -55,7 +56,7 @@ mod tests { let recv_vtx = genv.new_source(Type::string()); let _ret_vtx = - install_method_call(&mut genv, recv_vtx, "upcase".to_string(), vec![], None, None); + install_method_call(&mut genv, recv_vtx, "upcase".to_string(), vec![], None, None, false); // Box should be added assert_eq!(genv.box_count(), 1); diff --git a/core/src/analyzer/dispatch.rs b/core/src/analyzer/dispatch.rs index 3a56ca8..a1df3f0 100644 --- a/core/src/analyzer/dispatch.rs +++ b/core/src/analyzer/dispatch.rs @@ -88,6 +88,8 @@ pub enum NeedsChildKind<'a> { block: Option>, /// Arguments to the method call arguments: Vec>, + /// Whether this is a safe navigation call (`&.`) + safe_navigation: bool, }, /// Implicit self method call: method call without explicit receiver (implicit self) ImplicitSelfCall { @@ -215,6 +217,7 @@ pub fn dispatch_needs_child<'a>(node: &Node<'a>, source: &str) -> Option { let recv_vtx = super::install::install_node(genv, lenv, changes, source, &receiver)?; process_method_call_common( genv, lenv, changes, source, - MethodCallContext { recv_vtx, method_name, location, block, arguments }, + MethodCallContext { recv_vtx, method_name, location, block, arguments, safe_navigation }, ) } NeedsChildKind::ImplicitSelfCall { @@ -317,7 +321,8 @@ pub(crate) fn process_needs_child( }; process_method_call_common( genv, lenv, changes, source, - MethodCallContext { recv_vtx, method_name, location, block, arguments }, + // Implicit self calls cannot use safe navigation (`&.` requires explicit receiver) + MethodCallContext { recv_vtx, method_name, location, block, arguments, safe_navigation: false }, ) } NeedsChildKind::AttrDeclaration { kind, attr_names } => { @@ -360,6 +365,7 @@ struct MethodCallContext<'a> { location: SourceLocation, block: Option>, arguments: Vec>, + safe_navigation: bool, } /// MethodCall / ImplicitSelfCall common processing: @@ -377,6 +383,7 @@ fn process_method_call_common<'a>( location, block, arguments, + safe_navigation, } = ctx; if method_name == "!" { return Some(super::operators::process_not_operator(genv)); @@ -411,6 +418,7 @@ fn process_method_call_common<'a>( positional_arg_vtxs, kwarg_vtxs, location, + safe_navigation, )) } @@ -422,8 +430,9 @@ fn finish_method_call( arg_vtxs: Vec, kwarg_vtxs: Option>, location: SourceLocation, + safe_navigation: bool, ) -> VertexId { - install_method_call(genv, recv_vtx, method_name, arg_vtxs, kwarg_vtxs, Some(location)) + install_method_call(genv, recv_vtx, method_name, arg_vtxs, kwarg_vtxs, Some(location), safe_navigation) } #[cfg(test)] @@ -1398,4 +1407,82 @@ User.new.foo let ret_vtx = info.return_vertex.unwrap(); assert_eq!(get_type_show(&genv, ret_vtx), "String"); } + + // === Safe navigation operator (`&.`) tests === + + #[test] + fn test_safe_navigation_basic() { + let source = r#" +class User + def name + "Alice" + end +end + +User.new&.name +"#; + let genv = analyze(source); + assert!( + genv.type_errors.is_empty(), + "obj&.name should not produce type errors: {:?}", + genv.type_errors + ); + } + + #[test] + fn test_safe_navigation_undefined_method() { + let source = r#" +class User + def name + "Alice" + end +end + +User.new&.undefined_method +"#; + let genv = analyze(source); + assert!( + !genv.type_errors.is_empty(), + "obj&.undefined_method should produce a type error" + ); + } + + #[test] + fn test_safe_navigation_nil_receiver() { + let source = r#" +x = nil +x&.foo +"#; + let genv = analyze(source); + assert!( + genv.type_errors.is_empty(), + "nil&.foo should not produce type errors: {:?}", + genv.type_errors + ); + } + + #[test] + fn test_safe_navigation_chain() { + let source = r#" +class Profile + def name + "Alice" + end +end + +class User + def profile + Profile.new + end +end + +User.new&.profile&.name +"#; + let genv = analyze(source); + assert!( + genv.type_errors.is_empty(), + "chained safe navigation should not produce type errors: {:?}", + genv.type_errors + ); + } } diff --git a/core/src/analyzer/super_calls.rs b/core/src/analyzer/super_calls.rs index 11b1aba..4515be8 100644 --- a/core/src/analyzer/super_calls.rs +++ b/core/src/analyzer/super_calls.rs @@ -69,6 +69,7 @@ fn process_super_call( arg_vtxs, kw, Some(location), + false, // super calls cannot use safe navigation )) } diff --git a/core/src/graph/box.rs b/core/src/graph/box.rs index d1cf7e5..662f743 100644 --- a/core/src/graph/box.rs +++ b/core/src/graph/box.rs @@ -54,6 +54,8 @@ pub struct MethodCallBox { arg_vtxs: Vec, kwarg_vtxs: Option>, location: Option, // Source code location + /// Whether this is a safe navigation call (`&.`) + safe_navigation: bool, /// Number of times this box has been rescheduled reschedule_count: u8, } @@ -62,6 +64,7 @@ pub struct MethodCallBox { const MAX_RESCHEDULE_COUNT: u8 = 3; impl MethodCallBox { + #[allow(clippy::too_many_arguments)] pub fn new( id: BoxId, recv: VertexId, @@ -70,6 +73,7 @@ impl MethodCallBox { arg_vtxs: Vec, kwarg_vtxs: Option>, location: Option, + safe_navigation: bool, ) -> Self { Self { id, @@ -79,6 +83,7 @@ impl MethodCallBox { arg_vtxs, kwarg_vtxs, location, + safe_navigation, reschedule_count: 0, } } @@ -100,6 +105,13 @@ impl MethodCallBox { genv: &mut GlobalEnv, changes: &mut ChangeSet, ) { + // Safe navigation (`&.`): skip nil receiver entirely. + // Ruby's &. short-circuits: no method resolution, no argument evaluation, no error. + // The nil return type is added in run() after processing all receiver types. + if self.safe_navigation && matches!(recv_ty, Type::Nil) { + return; + } + if let Some(method_info) = genv.resolve_method(recv_ty, &self.method_name) { if let Some(return_vtx) = method_info.return_vertex { // User-defined method: connect body's return vertex to call site @@ -186,8 +198,14 @@ impl BoxTrait for MethodCallBox { return; } - for recv_ty in recv_types { - self.process_recv_type(&recv_ty, genv, changes); + for recv_ty in &recv_types { + self.process_recv_type(recv_ty, genv, changes); + } + + // Safe navigation (`&.`): if receiver can be nil, return type includes nil + if self.safe_navigation && recv_types.iter().any(|t| matches!(t, Type::Nil)) { + let nil_src = genv.new_source(Type::Nil); + changes.add_edge(nil_src, self.ret); } } } @@ -357,6 +375,7 @@ mod tests { vec![], None, None, // No location in test + false, ); // Execute Box @@ -390,6 +409,7 @@ mod tests { vec![], None, None, // No location in test + false, ); let mut changes = ChangeSet::new(); @@ -601,6 +621,7 @@ mod tests { vec![], None, None, + false, ); genv.register_box(box_id, Box::new(call_box)); @@ -632,6 +653,7 @@ mod tests { vec![], None, None, + false, ); genv.register_box(inner_box_id, Box::new(inner_call)); @@ -661,6 +683,7 @@ mod tests { vec![arg_vtx], None, None, + false, ); genv.register_box(call_box_id, Box::new(call_box)); @@ -710,6 +733,7 @@ mod tests { vec![], Some(kwarg_vtxs), None, + false, ); genv.register_box(box_id, Box::new(call_box)); @@ -755,6 +779,7 @@ mod tests { vec![], Some(kwarg_vtxs), None, + false, ); genv.register_box(box_id, Box::new(call_box)); @@ -763,4 +788,58 @@ mod tests { // param_vtx should remain untyped (name mismatch → no propagation) assert_eq!(genv.get_vertex(param_vtx).unwrap().show(), "untyped"); } + + // === Safe navigation (`&.`) unit tests === + + fn setup_safe_nav_test(recv_types: &[Type], safe_navigation: bool) -> (GlobalEnv, VertexId) { + let mut genv = GlobalEnv::new(); + genv.register_builtin_method(Type::string(), "upcase", Type::string()); + + let recv_vtx = genv.new_vertex(); + for ty in recv_types { + let src = genv.new_source(ty.clone()); + genv.add_edge(src, recv_vtx); + } + + let ret_vtx = genv.new_vertex(); + let box_id = genv.alloc_box_id(); + let call_box = MethodCallBox::new( + box_id, recv_vtx, "upcase".to_string(), ret_vtx, + vec![], None, None, safe_navigation, + ); + genv.register_box(box_id, Box::new(call_box)); + genv.run_all(); + (genv, ret_vtx) + } + + #[test] + fn test_safe_navigation_nil_receiver_no_error() { + let (genv, _) = setup_safe_nav_test(&[Type::Nil], true); + assert!(genv.type_errors.is_empty(), "nil&.upcase should not produce type errors"); + } + + #[test] + fn test_safe_navigation_nilable_receiver_return_includes_nil() { + let (genv, ret_vtx) = setup_safe_nav_test(&[Type::string(), Type::Nil], true); + assert!(genv.type_errors.is_empty()); + + let ret_vertex = genv.get_vertex(ret_vtx).unwrap(); + let type_names: Vec = ret_vertex.types.keys().map(|t| t.show()).collect(); + assert!(type_names.contains(&"String".to_string()), "should include String: {:?}", type_names); + assert!(type_names.contains(&"nil".to_string()), "should include nil: {:?}", type_names); + } + + #[test] + fn test_safe_navigation_non_nil_receiver_no_spurious_nil() { + let (genv, ret_vtx) = setup_safe_nav_test(&[Type::string()], true); + assert_eq!(genv.get_vertex(ret_vtx).unwrap().show(), "String", + "non-nil receiver with &. should return String, not String | nil"); + } + + #[test] + fn test_normal_call_does_not_add_nil() { + let (genv, ret_vtx) = setup_safe_nav_test(&[Type::string(), Type::Nil], false); + assert_eq!(genv.get_vertex(ret_vtx).unwrap().show(), "String", + "normal call (.) should not add nil to return type"); + } }