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
5 changes: 4 additions & 1 deletion core/src/analyzer/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use super::definitions::{process_class_node, process_def_node, process_module_no
use super::exceptions::{process_begin_node, process_rescue_modifier_node};
use super::dispatch::{dispatch_needs_child, dispatch_simple, process_needs_child, DispatchResult};
use super::literals::install_literal_node;
use super::loops::{process_until_node, process_while_node};
use super::loops::{process_for_node, process_until_node, process_while_node};
use super::operators::{process_and_node, process_or_node};
use super::parentheses::process_parentheses_node;
use super::returns::process_return_node;
Expand Down Expand Up @@ -95,6 +95,9 @@ pub(crate) fn install_node(
if let Some(until_node) = node.as_until_node() {
return process_until_node(genv, lenv, changes, source, &until_node);
}
if let Some(for_node) = node.as_for_node() {
return process_for_node(genv, lenv, changes, source, &for_node);
}

if let Some(paren_node) = node.as_parentheses_node() {
return process_parentheses_node(genv, lenv, changes, source, &paren_node);
Expand Down
127 changes: 126 additions & 1 deletion core/src/analyzer/loops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
use crate::env::{GlobalEnv, LocalEnv};
use crate::graph::{ChangeSet, VertexId};
use crate::types::Type;
use ruby_prism::{UntilNode, WhileNode};
use ruby_prism::{ForNode, UntilNode, WhileNode};

use super::bytes_to_name;
use super::install::{install_node, install_statements};

/// Process WhileNode: `while predicate; statements; end`
Expand Down Expand Up @@ -50,6 +51,48 @@ pub(crate) fn process_until_node(
Some(genv.new_source(Type::Nil))
}

/// Process ForNode: `for index in collection; statements; end`
///
/// Ruby's `for` does NOT create a new scope — the loop variable persists
/// after the loop. This differs from `collection.each { |x| }` which
/// creates a block scope.
///
/// Returns nil (consistent with while/until; Ruby's for returns the
/// collection, but the return value is rarely used in practice).
pub(crate) fn process_for_node(
genv: &mut GlobalEnv,
lenv: &mut LocalEnv,
changes: &mut ChangeSet,
source: &str,
for_node: &ForNode,
) -> Option<VertexId> {
let collection_vtx = install_node(genv, lenv, changes, source, &for_node.collection());

// TODO: MultiTargetNode (e.g., `for a, b in [[1, "x"]]`) is not yet supported
if let Some(target) = for_node.index().as_local_variable_target_node() {
let name = bytes_to_name(target.name().as_slice());
let var_vtx = genv.new_vertex();

// Array[T] or Range[T] → loop var gets T
let elem_type = collection_vtx
.and_then(|vtx| genv.get_source(vtx))
.and_then(|src| src.ty.type_args())
.and_then(|args| args.first().cloned());
if let Some(ty) = elem_type {
let elem_src = genv.new_source(ty);
genv.add_edge(elem_src, var_vtx);
}

lenv.new_var(name, var_vtx);
}

if let Some(stmts) = for_node.statements() {
install_statements(genv, lenv, changes, source, &stmts);
}

Some(genv.new_source(Type::Nil))
}

#[cfg(test)]
mod tests {
use crate::analyzer::install::AstInstaller;
Expand Down Expand Up @@ -173,4 +216,86 @@ end
let ret_vtx = info.return_vertex.unwrap();
assert_eq!(get_type_show(&genv, ret_vtx), "nil");
}

// --- for loop tests ---

#[test]
fn test_for_returns_nil() {
let source = r#"
class Foo
def bar
for x in [1, 2, 3]
x
end
end
end
"#;
let genv = analyze(source);
let info = genv
.resolve_method(&Type::instance("Foo"), "bar")
.expect("Foo#bar should be registered");
let ret_vtx = info.return_vertex.unwrap();
assert_eq!(get_type_show(&genv, ret_vtx), "nil");
}

#[test]
fn test_for_variable_type_from_array() {
let source = r#"
for item in [1, 2, 3]
item
end
"#;
// Should not panic; loop variable is registered
analyze(source);
}

#[test]
fn test_for_variable_persists_after_loop() {
// for does NOT create a new scope — variable persists
let source = r#"
class Foo
def bar
for x in [1, 2, 3]
end
x
end
end
"#;
// Should not panic — x is accessible after the loop
analyze(source);
}

#[test]
fn test_for_empty_body() {
let source = r#"
class Foo
def bar
for x in [1, 2, 3]
end
end
end
"#;
let genv = analyze(source);
let info = genv
.resolve_method(&Type::instance("Foo"), "bar")
.expect("Foo#bar should be registered");
let ret_vtx = info.return_vertex.unwrap();
assert_eq!(get_type_show(&genv, ret_vtx), "nil");
}

#[test]
fn test_for_with_method_call_in_body() {
// Should not panic — method call on loop variable is processed
// (type error check requires RBS, covered by Ruby integration test)
let source = r#"
class Foo
def bar
for item in ["hello", "world"]
item.upcase
end
end
end
"#;
analyze(source);
}
}
28 changes: 28 additions & 0 deletions test/loop_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,20 @@ def bar
assert_no_check_errors(source)
end

def test_for_loop_basic_no_error
source = <<~RUBY
class Foo
def bar
for item in ["hello", "world"]
item.upcase
end
end
end
RUBY

assert_no_check_errors(source)
end

# ============================================
# Error Detection (check CLI)
# ============================================
Expand All @@ -59,6 +73,20 @@ def baz
assert_check_error(source, method_name: 'upcase', receiver_type: 'Integer')
end

def test_for_loop_detects_type_error
source = <<~RUBY
class Foo
def bar
for item in [1, 2, 3]
item.upcase
end
end
end
RUBY

assert_check_error(source, method_name: 'upcase', receiver_type: 'Integer')
end

def test_until_loop_detects_type_error
source = <<~RUBY
class Foo
Expand Down