Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions core/src/analyzer/calls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,15 @@ pub fn install_method_call(
arg_vtxs: Vec<VertexId>,
kwarg_vtxs: Option<HashMap<String, VertexId>>,
location: Option<SourceLocation>,
safe_navigation: bool,
) -> VertexId {
// Create Vertex for return value
let ret_vtx = genv.new_vertex();

// 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
Expand All @@ -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());
Expand All @@ -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);
Expand Down
93 changes: 90 additions & 3 deletions core/src/analyzer/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ pub enum NeedsChildKind<'a> {
block: Option<Node<'a>>,
/// Arguments to the method call
arguments: Vec<Node<'a>>,
/// Whether this is a safe navigation call (`&.`)
safe_navigation: bool,
},
/// Implicit self method call: method call without explicit receiver (implicit self)
ImplicitSelfCall {
Expand Down Expand Up @@ -215,6 +217,7 @@ pub fn dispatch_needs_child<'a>(node: &Node<'a>, source: &str) -> Option<NeedsCh
location,
block,
arguments,
safe_navigation: call_node.is_safe_navigation(),
});
} else {
// No receiver: implicit self method call (e.g., `name`, `puts "hello"`)
Expand Down Expand Up @@ -296,11 +299,12 @@ pub(crate) fn process_needs_child(
location,
block,
arguments,
safe_navigation,
} => {
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 {
Expand All @@ -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 } => {
Expand Down Expand Up @@ -360,6 +365,7 @@ struct MethodCallContext<'a> {
location: SourceLocation,
block: Option<Node<'a>>,
arguments: Vec<Node<'a>>,
safe_navigation: bool,
}

/// MethodCall / ImplicitSelfCall common processing:
Expand All @@ -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));
Expand Down Expand Up @@ -411,6 +418,7 @@ fn process_method_call_common<'a>(
positional_arg_vtxs,
kwarg_vtxs,
location,
safe_navigation,
))
}

Expand All @@ -422,8 +430,9 @@ fn finish_method_call(
arg_vtxs: Vec<VertexId>,
kwarg_vtxs: Option<HashMap<String, VertexId>>,
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)]
Expand Down Expand Up @@ -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
);
}
}
1 change: 1 addition & 0 deletions core/src/analyzer/super_calls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ fn process_super_call(
arg_vtxs,
kw,
Some(location),
false, // super calls cannot use safe navigation
))
}

Expand Down
83 changes: 81 additions & 2 deletions core/src/graph/box.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ pub struct MethodCallBox {
arg_vtxs: Vec<VertexId>,
kwarg_vtxs: Option<HashMap<String, VertexId>>,
location: Option<SourceLocation>, // Source code location
/// Whether this is a safe navigation call (`&.`)
safe_navigation: bool,
/// Number of times this box has been rescheduled
reschedule_count: u8,
}
Expand All @@ -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,
Expand All @@ -70,6 +73,7 @@ impl MethodCallBox {
arg_vtxs: Vec<VertexId>,
kwarg_vtxs: Option<HashMap<String, VertexId>>,
location: Option<SourceLocation>,
safe_navigation: bool,
) -> Self {
Self {
id,
Expand All @@ -79,6 +83,7 @@ impl MethodCallBox {
arg_vtxs,
kwarg_vtxs,
location,
safe_navigation,
reschedule_count: 0,
}
}
Expand All @@ -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
Expand Down Expand Up @@ -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);
}
}
}
Expand Down Expand Up @@ -357,6 +375,7 @@ mod tests {
vec![],
None,
None, // No location in test
false,
);

// Execute Box
Expand Down Expand Up @@ -390,6 +409,7 @@ mod tests {
vec![],
None,
None, // No location in test
false,
);

let mut changes = ChangeSet::new();
Expand Down Expand Up @@ -601,6 +621,7 @@ mod tests {
vec![],
None,
None,
false,
);
genv.register_box(box_id, Box::new(call_box));

Expand Down Expand Up @@ -632,6 +653,7 @@ mod tests {
vec![],
None,
None,
false,
);
genv.register_box(inner_box_id, Box::new(inner_call));

Expand Down Expand Up @@ -661,6 +683,7 @@ mod tests {
vec![arg_vtx],
None,
None,
false,
);
genv.register_box(call_box_id, Box::new(call_box));

Expand Down Expand Up @@ -710,6 +733,7 @@ mod tests {
vec![],
Some(kwarg_vtxs),
None,
false,
);
genv.register_box(box_id, Box::new(call_box));

Expand Down Expand Up @@ -755,6 +779,7 @@ mod tests {
vec![],
Some(kwarg_vtxs),
None,
false,
);
genv.register_box(box_id, Box::new(call_box));

Expand All @@ -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<String> = 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");
}
}