diff --git a/spec/compiler/semantic/closure_spec.cr b/spec/compiler/semantic/closure_spec.cr index 442876458315..d877640ad0b9 100644 --- a/spec/compiler/semantic/closure_spec.cr +++ b/spec/compiler/semantic/closure_spec.cr @@ -549,4 +549,200 @@ describe "Semantic: closure" do call = node.expressions[-2].as(Call) call.target_def.self_closured?.should be_false end + + it "correctly detects previous var as closured (#5609)" do + assert_error %( + def block(&block) + block.call + end + def times + yield + yield + end + x = 1 + times do + if x.is_a?(Int32) + x &+ 2 + end + block do + x = "hello" + end + end + ), + "undefined method '&+' for String" + end + + it "doesn't assign all types to metavar if closured but only assigned to once" do + semantic(%( + def capture(&block) + block + end + x = 1 == 2 ? 1 : nil + if x + capture do + x &+ 1 + end + end + )) + end + + it "does assign all types to metavar if closured but only assigned to once in a loop" do + assert_error %( + def capture(&block) + block + end + while 1 == 1 + x = 1 == 2 ? 1 : nil + if x + capture do + x &+ 1 + end + end + end + ), + "undefined method '&+'" + end + + it "does assign all types to metavar if closured but only assigned to once in a loop through block" do + assert_error %( + def capture(&block) + block + end + + def loop + while 1 == 1 + yield + end + end + + x = 1 + loop do + x = 1 == 2 ? 1 : nil + if x + capture do + x &+ 1 + end + end + end + ), + "undefined method '&+'" + end + + it "does assign all types to metavar if closured but only assigned to once in a loop through captured block" do + assert_error %( + def capture(&block) + block + end + + def loop(&block) + while 1 == 1 + block.call + end + end + + x = 1 + loop do + x = 1 == 2 ? 1 : nil + if x + capture do + x &+ 1 + end + end + end + ), + "undefined method '&+'" + end + + it "doesn't assign all types to metavar if closured but declared inside block and never re-assigned" do + assert_no_errors %( + def capture(&block) + block + end + + def loop(&block) + yield + end + + loop do + x = 1 == 2 ? 1 : nil + if x + capture do + x &+ 1 + end + end + end + ) + end + + it "doesn't assign all types to metavar if closured but declared inside block and re-assigned inside the same context before the closure" do + assert_no_errors %( + def capture(&block) + block + end + + def loop(&block) + yield + end + + loop do + x = 1 == 2 ? 1 : nil + x = 1 == 2 ? 1 : nil + if x + capture do + x &+ 1 + end + end + end + ) + end + + it "is considered as closure if assigned once but comes from a method arg" do + assert_error %( + def capture(&block) + block + end + def foo(x) + capture do + x &+ 1 + end + x = 1 == 2 ? 1 : nil + end + foo(1) + ), + "undefined method '&+'" + end + + it "considers var as closure-readonly if it was assigned multiple times before it was closured" do + assert_no_errors(%( + def capture(&block) + block + end + + x = "hello" + x = 1 + + capture do + x &+ 1 + end + )) + end + + it "correctly captures type of closured block arg" do + assert_type(%( + def capture(&block) + block.call + end + def foo + yield nil + end + z = nil + foo do |x| + capture do + x = 1 + end + z = x + end + z + )) { nilable int32 } + end end diff --git a/src/compiler/crystal/semantic/ast.cr b/src/compiler/crystal/semantic/ast.cr index 631432b98fa9..e3c1b8efc4cf 100644 --- a/src/compiler/crystal/semantic/ast.cr +++ b/src/compiler/crystal/semantic/ast.cr @@ -431,14 +431,40 @@ module Crystal # A variable is closured if it's used in a ProcLiteral context # where it wasn't created. - property? closured = false + getter? closured = false # Is this metavar assigned a value? property? assigned_to = false + # Is this metavar closured in a mutable way? + # This means it's closured and it got a value assigned to it more than once. + # If that's the case, when it's closured then all local variable related to + # it will also be bound to it. + property? mutably_closured = false + + # Local variables associated with this meta variable. + # Can be Var or MetaVar. + property(local_vars) { [] of ASTNode } + def initialize(@name : String, @type : Type? = nil) end + # Marks this variable as closured. + def mark_as_closured + @closured = true + + return unless mutably_closured? + + local_vars = @local_vars + return unless local_vars + + # If a meta var is not readonly and it became a closure we must + # bind all previously related local vars to it so that + # they get all types assigned to it. + local_vars.each &.bind_to self + local_vars = nil + end + # True if this variable belongs to the given context # but must be allocated in a closure. def closure_in?(context) @@ -450,6 +476,11 @@ module Crystal @context.same?(context) end + # Is this metavar associated with any local vars? + def local_vars? + @local_vars + end + def ==(other : self) name == other.name end @@ -466,6 +497,7 @@ module Crystal end io << " (nil-if-read)" if nil_if_read? io << " (closured)" if closured? + io << " (mutably-closured)" if mutably_closured? io << " (assigned-to)" if assigned_to? io << " (object id: #{object_id})" end diff --git a/src/compiler/crystal/semantic/main_visitor.cr b/src/compiler/crystal/semantic/main_visitor.cr index eb87b51a816e..b5a58dc847a6 100644 --- a/src/compiler/crystal/semantic/main_visitor.cr +++ b/src/compiler/crystal/semantic/main_visitor.cr @@ -367,9 +367,7 @@ module Crystal node.bind_to(@program.nil_var) end - if meta_var.closured? - var.bind_to(meta_var) - end + check_mutably_closured meta_var, var node.bind_to(var) @@ -481,7 +479,7 @@ module Crystal node.raise "can't infer type of type declaration" end - meta_var = @meta_vars[var.name] ||= new_meta_var(var.name) + meta_var, _ = assign_to_meta_var(var.name) if (existing_type = meta_var.type?) && existing_type != var_type node.raise "variable '#{var.name}' already declared with type #{existing_type}" end @@ -763,7 +761,8 @@ module Crystal value.accept self var_name = target.name - meta_var = (@meta_vars[var_name] ||= new_meta_var(var_name)) + meta_var, meta_var_existed = assign_to_meta_var(var_name) + freeze_type = meta_var.freeze_type if freeze_type @@ -804,7 +803,7 @@ module Crystal end meta_var.assigned_to = true - check_closured meta_var + check_closured meta_var, mark_as_mutably_closured: meta_var_existed simple_var = MetaVar.new(var_name) @@ -815,9 +814,7 @@ module Crystal else simple_var.bind_to(target) - if meta_var.closured? - simple_var.bind_to(meta_var) - end + check_mutably_closured(meta_var, simple_var) end @vars[var_name] = simple_var @@ -883,7 +880,7 @@ module Crystal # Don't track instance variables nilability (for example, if they were # just assigned inside a branch) if they have an initializer unless scope.has_instance_var_initializer?(var_name) - meta_var = (@meta_vars[var_name] ||= new_meta_var(var_name)) + meta_var, _ = assign_to_meta_var(var_name) meta_var.bind_to value meta_var.assigned_to = true @@ -2701,10 +2698,11 @@ module Crystal if node_name = node.name var = @vars[node_name] = new_meta_var(node_name) - meta_var = (@meta_vars[node_name] ||= new_meta_var(node_name)) - check_closured(meta_var) + meta_var, _ = assign_to_meta_var(node_name) meta_var.bind_to(var) meta_var.assigned_to = true + check_closured(meta_var) + check_mutably_closured(meta_var, var) if types unified_type = @program.type_merge(types).not_nil! @@ -3167,7 +3165,7 @@ module Crystal match_context.try &.free_vars end - def check_closured(var) + def check_closured(var, mark_as_mutably_closured : Bool = false) return if @typeof_nest > 0 if var.name == "self" @@ -3177,7 +3175,12 @@ module Crystal context = current_context var_context = var.context - if !var_context.same?(context) + if var_context.same?(context) + var_context = var_context.context if var_context.is_a?(Block) + if var.closured? + mark_as_closured(var, var_context, mark_as_mutably_closured) + end + else # If the contexts are not the same, it might be that we are in a block # inside a method, or a block inside another block. We don't want # those cases to closure a variable. So if any context is a block @@ -3188,19 +3191,30 @@ module Crystal closured = !context.same?(var_context) if closured - var.closured = true + mark_as_closured(var, var_context, mark_as_mutably_closured) + end + end + end - # Go up and mark proc literal defs as closured until we get - # to the context where the variable is defined - visitor = self - while visitor - visitor_context = visitor.closure_context - break if visitor_context == var_context + def mark_as_closured(var, var_context, mark_as_mutably_closured : Bool) + # This is a bit tricky: when we assign to a variable we create a new metavar + # for it if it didn't exist. If it did exist, and right now we are forming + # a closure, then we also want to mark it as readonly. + # We already do this in `assign_to_meta_var` but that's done **before** + # we detect a closure in an assignment. So that logic needs to be replicated here, + # and it must happen before we actually mark is as closured. + var.mutably_closured = true if mark_as_mutably_closured + var.mark_as_closured - visitor_context.closure = true if visitor_context.is_a?(Def) - visitor = visitor.parent - end - end + # Go up and mark proc literal defs as closured until we get + # to the context where the variable is defined + visitor = self + while visitor + visitor_context = visitor.closure_context + break if visitor_context == var_context + + visitor_context.closure = true if visitor_context.is_a?(Def) + visitor = visitor.parent end end @@ -3332,7 +3346,7 @@ module Crystal end def define_special_var(name, value) - meta_var = (@meta_vars[name] ||= new_meta_var(name)) + meta_var, _ = assign_to_meta_var(name) meta_var.bind_to value meta_var.bind_to program.nil_var unless meta_var.dependencies.any? &.same?(program.nil_var) meta_var.assigned_to = true @@ -3348,6 +3362,39 @@ module Crystal meta_var end + def assign_to_meta_var(name, context = current_context) + meta_var = @meta_vars[name]? + meta_var_existed = !!meta_var + if meta_var + # This var gets assigned a new value and it already existed before this line. + # If it's also a closured var it means it has become mutably closured. + meta_var.mutably_closured = true if meta_var.closured? + else + @meta_vars[name] = meta_var = new_meta_var(name) + end + + # If a variable is being assigned inside a while then it's considered + # as mutably closured: it will get a value assigned to it multiple times + # exactly because it's in a loop. + meta_var.mutably_closured = true if inside_while? + + # If a variable is being assigned to inside a block: + # - if the variable is a new variable then there's no need to mark is a mutably + # closured because unless it gets assigned again it will be a different + # variable alloction each time + # - if the variable already existed but it's assigned in the same context + # as before, if it's not closured already then it still shouldn't + # be marked as mutably closured + # - otherwise, we mark it as mutably closured. The block might happen + # in a while loop, or invoked multiple times: we don't know, so we must + # mark is as such until the compiler gets smarter (if really necessary) + if @block && meta_var_existed && !current_context.same?(meta_var.context) + meta_var.mutably_closured = true + end + + {meta_var, meta_var_existed} + end + def block=(@block) @block_context = @block end @@ -3356,6 +3403,10 @@ module Crystal @untyped_def || @block_context end + def inside_while? + !@while_stack.empty? + end + def lookup_class_var(node) class_var_owner = class_var_owner(node) var = class_var_owner.lookup_class_var?(node.name) @@ -3412,6 +3463,18 @@ module Crystal nil_exp end + # If the meta_var is closured but not readonly, then bind var + # to it (it gets all types assigned to meta_var). + # Otherwise, add it to the local vars so that they could be + # bond later on, if the meta_var stops being readonly. + def check_mutably_closured(meta_var, var) + if meta_var.closured? && meta_var.mutably_closured? + var.bind_to(meta_var) + else + meta_var.local_vars << var + end + end + def visit(node : When | Unless | Until | MacroLiteral | OpAssign) raise "BUG: #{node.class_desc} node '#{node}' (#{node.location}) should have been eliminated in normalize" end