From e826feaab82dd01dd9762e00c63c4268204087ec Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Thu, 18 Jun 2026 15:06:26 -0400 Subject: [PATCH 1/2] Don't generalize capturing lambdas in argument position A static-dispatch method chain on a not-yet-concrete value, such as letters.to_utf8().contains(...) where letters is a string literal flowing through find_first, could report a spurious MISSING METHOD error. The cause was that a capturing lambda passed directly as a call argument was generalized at its own rank, before the caller pinned its parameter types, freezing the lambda body's dispatch chain as a generalized scheme. When the predicate was then instantiated, only the instantiated copy's dispatches resolved, leaving the original CIR node's type variable an unresolved flex dispatcher that the end-of-check ambiguity sweep flagged. A bare (non-capturing) lambda in argument position already skips generalization via the !is_call_arg guard in shouldGeneralize; the closure case slipped through because checkExpr clears checking_call_arg before delegating to the inner lambda. Re-assert the flag in the e_closure case so a capturing argument lambda is treated the same as a bare one and its dispatch chain resolves in place once the element type is pinned. --- src/check/Check.zig | 15 +- ...tch_on_quote_literal_through_find_first.md | 147 ++++++++++++++++++ .../lambda_capture/capture_from_block.md | 2 +- .../lambda_capture/simple_capture.md | 2 +- 4 files changed, 161 insertions(+), 5 deletions(-) create mode 100644 test/snapshots/dispatch_on_quote_literal_through_find_first.md diff --git a/src/check/Check.zig b/src/check/Check.zig index fe537d7ee28..95c048e3334 100644 --- a/src/check/Check.zig +++ b/src/check/Check.zig @@ -5794,8 +5794,11 @@ fn checkExpr(self: *Self, expr_idx: CIR.Expr.Idx, env: *Env, expected: Expected) const expr_var_raw = ModuleEnv.varFrom(expr_idx); // Consume the checking_call_arg flag: it applies only to this immediate - // checkExpr call and must not propagate to recursive calls (e.g. e_closure - // delegating to its inner e_lambda, or nested call arguments). + // checkExpr call and must not propagate to recursive calls (e.g. nested call + // arguments). The one exception is `e_closure`, which is only the capture + // wrapper around its inner `e_lambda`: the lambda is the actual argument + // value, so the closure re-asserts this flag before delegating to it (see the + // `.e_closure` case) to keep an argument lambda from being generalized. const is_call_arg = self.checking_call_arg; self.checking_call_arg = false; @@ -6815,7 +6818,13 @@ fn checkExpr(self: *Self, expr_idx: CIR.Expr.Idx, env: *Env, expected: Expected) }, .e_closure => |closure| { // Here, we must forward the expected valued to the inner lambda, so - // the annotation type is created at the same rank as the expr + // the annotation type is created at the same rank as the expr. + // A closure is only the capture wrapper around its inner lambda, so + // the lambda inherits this closure's call-arg status: an argument + // lambda must NOT be generalized, or its body's static-dispatch chain + // would be quantified before the caller pins the parameter types, + // leaving the original (un-instantiated) dispatch nodes unresolved. + self.checking_call_arg = is_call_arg; does_fx = try self.checkExpr(closure.lambda_idx, env, expected) or does_fx; const lambda_var = ModuleEnv.varFrom(closure.lambda_idx); diff --git a/test/snapshots/dispatch_on_quote_literal_through_find_first.md b/test/snapshots/dispatch_on_quote_literal_through_find_first.md new file mode 100644 index 00000000000..0dba7cb40ce --- /dev/null +++ b/test/snapshots/dispatch_on_quote_literal_through_find_first.md @@ -0,0 +1,147 @@ +# META +~~~ini +description=Static dispatch chain on a find_first predicate's string-literal param resolves; the ?? default literal that previously orphaned the dispatch (when the capturing closure argument was prematurely generalized) no longer breaks it +type=snippet +~~~ +# SOURCE +~~~roc +foo : U8 -> Str +foo = |letter| { + val = ["hello", "world"].find_first(|letters| letters.to_utf8().contains(letter)) ?? "" + val +} +~~~ +# EXPECTED +NIL +# PROBLEMS +NIL +# TOKENS +~~~zig +LowerIdent,OpColon,UpperIdent,OpArrow,UpperIdent, +LowerIdent,OpAssign,OpBar,LowerIdent,OpBar,OpenCurly, +LowerIdent,OpAssign,OpenSquare,StringStart,StringPart,StringEnd,Comma,StringStart,StringPart,StringEnd,CloseSquare,NoSpaceDotLowerIdent,NoSpaceOpenRound,OpBar,LowerIdent,OpBar,LowerIdent,NoSpaceDotLowerIdent,NoSpaceOpenRound,CloseRound,NoSpaceDotLowerIdent,NoSpaceOpenRound,LowerIdent,CloseRound,CloseRound,OpDoubleQuestion,StringStart,StringPart,StringEnd, +LowerIdent, +CloseCurly, +EndOfFile, +~~~ +# PARSE +~~~clojure +(file + (type-module) + (statements + (s-type-anno (name "foo") + (ty-fn + (ty (name "U8")) + (ty (name "Str")))) + (s-decl + (p-ident (raw "foo")) + (e-lambda + (args + (p-ident (raw "letter"))) + (e-block + (statements + (s-decl + (p-ident (raw "val")) + (e-binop (op "??") + (e-method-call (method ".find_first") + (receiver + (e-list + (e-string + (e-string-part (raw "hello"))) + (e-string + (e-string-part (raw "world"))))) + (args + (e-lambda + (args + (p-ident (raw "letters"))) + (e-method-call (method ".contains") + (receiver + (e-method-call (method ".to_utf8") + (receiver + (e-ident (raw "letters"))) + (args))) + (args + (e-ident (raw "letter"))))))) + (e-string + (e-string-part (raw ""))))) + (e-ident (raw "val")))))))) +~~~ +# FORMATTED +~~~roc +foo : U8 -> Str +foo = |letter| { + val = ["hello", "world"].find_first(|letters| letters.to_utf8().contains(letter)) ?? "" + val +} +~~~ +# CANONICALIZE +~~~clojure +(can-ir + (d-let + (p-assign (ident "foo")) + (e-lambda + (args + (p-assign (ident "letter"))) + (e-block + (s-let + (p-assign (ident "val")) + (e-match + (match + (cond + (e-dispatch-call (method "find_first") (constraint-fn-var 94) + (receiver + (e-list + (elems + (e-string + (e-literal (string "hello"))) + (e-string + (e-literal (string "world")))))) + (args + (e-closure + (captures + (capture (ident "letter"))) + (e-lambda + (args + (p-assign (ident "letters"))) + (e-dispatch-call (method "contains") (constraint-fn-var 92) + (receiver + (e-dispatch-call (method "to_utf8") (constraint-fn-var 90) + (receiver + (e-lookup-local + (p-assign (ident "letters")))) + (args))) + (args + (e-lookup-local + (p-assign (ident "letter")))))))))) + (branches + (branch + (patterns + (pattern (degenerate false) + (p-nominal-external (builtin) + (p-applied-tag)))) + (value + (e-lookup-local + (p-assign (ident "#ok"))))) + (branch + (patterns + (pattern (degenerate false) + (p-nominal-external (builtin) + (p-applied-tag)))) + (value + (e-string + (e-literal (string ""))))))))) + (e-lookup-local + (p-assign (ident "val"))))) + (annotation + (ty-fn (effectful false) + (ty-lookup (name "U8") (builtin)) + (ty-lookup (name "Str") (builtin)))))) +~~~ +# TYPES +~~~clojure +(inferred-types + (defs + (patt (type "U8 -> Str"))) + (expressions + (expr (type "U8 -> Str")))) +~~~ diff --git a/test/snapshots/lambda_capture/capture_from_block.md b/test/snapshots/lambda_capture/capture_from_block.md index ae5a9f9a08f..9d6707be9bf 100644 --- a/test/snapshots/lambda_capture/capture_from_block.md +++ b/test/snapshots/lambda_capture/capture_from_block.md @@ -60,7 +60,7 @@ EndOfFile, (e-num (value "10"))) (s-let (p-assign (ident "b")) - (e-call (constraint-fn-var 116) + (e-call (constraint-fn-var 114) (e-closure (captures (capture (ident "a"))) diff --git a/test/snapshots/lambda_capture/simple_capture.md b/test/snapshots/lambda_capture/simple_capture.md index 003ded1b609..64b804a7a90 100644 --- a/test/snapshots/lambda_capture/simple_capture.md +++ b/test/snapshots/lambda_capture/simple_capture.md @@ -58,7 +58,7 @@ EndOfFile, (e-num (value "5"))) (s-let (p-assign (ident "y")) - (e-call (constraint-fn-var 82) + (e-call (constraint-fn-var 80) (e-closure (captures (capture (ident "x"))) From 350e641f9c235b4a36699250c4d1c704f0f24a36 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Thu, 18 Jun 2026 15:54:09 -0400 Subject: [PATCH 2/2] Regenerate stale snapshot (grammar drift in type-mismatch message) report.zig already emits "the annotation says it should be"; this snapshot still had the older "annotation say" wording, so a full snapshot regen (run-check-snapshots in CI) leaves it dirty. Regenerate it to match. --- .../generalize_annotated_value_unannotated_not_generalized.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/snapshots/generalize_annotated_value_unannotated_not_generalized.md b/test/snapshots/generalize_annotated_value_unannotated_not_generalized.md index c3de43ebabd..e628e2505b4 100644 --- a/test/snapshots/generalize_annotated_value_unannotated_not_generalized.md +++ b/test/snapshots/generalize_annotated_value_unannotated_not_generalized.md @@ -32,7 +32,7 @@ It has the type: List(U64) -But the annotation say it should be: +But the annotation says it should be: List(Str)