diff --git a/DESIGN.md b/DESIGN.md index c706f54..cdfefec 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -127,7 +127,12 @@ collected like type variables (`collect_effect_vars`), generalized in the extern variable into the innermost `{io, e}`, so `let pure` still rejects an impure pipeline. A `->{e}` on the innermost arrow counts as annotated (skips the io-by-default rule, asserting "exactly the callback's effect"). In a `type` declaration effect variables remain an error — ADT/record fields -would need effect-*parameterized* types, which stays out of scope. +would need effect-*parameterized* types, which stays out of scope. A callback arrow written without a +variable is closed, and an `async { }` handler (which performs `async`) never fits one, so an argument +whose arrow meets a closed declared arrow is reported at the argument with both effects and the +`->{e}` fix (`` `handle` performs {io, async}, but `serve` takes this callback as `Writer -> Async +unit`, which performs nothing; … `Reader -> Writer ->{e} Async unit` ``, #180), not the inferred set, +which would accept only callbacks performing exactly that. **Why inference-first (the chosen model).** Effects follow Koka/Flix/Unison — **inferred, never written in ordinary code** — rather than effects-as-values (Haskell `IO`: `do`/`<-`/wrapper types) or diff --git a/src/types/mod.rs b/src/types/mod.rs index 274a002..d5d6468 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -8770,13 +8770,97 @@ impl Infer { let arg_ty = self.infer_expr(arg, env)?; let result = self.fresh(); let latent = self.fresh_eff(); - let expected = Ty::Fun(Box::new(arg_ty), Box::new(result.clone()), latent.clone()); - self.unify(&func_ty, &expected, span)?; + let expected = Ty::Fun( + Box::new(arg_ty.clone()), + Box::new(result.clone()), + latent.clone(), + ); + self.unify(&func_ty, &expected, span) + .map_err(|e| self.closed_callback_hint(func, arg, &func_ty, &arg_ty, e))?; let latent = self.apply_eff(&latent); self.perform(&latent); Ok(self.apply(&result)) } + /// Turn an effect mismatch on a callback argument into guidance (#180). An + /// arrow with a closed effect set comes from a declaration, almost always an + /// `extern` written `(Reader -> Writer -> Async unit)`, and a function that + /// does anything (an `async { }` block performs `async`, §4) cannot meet it. + /// The fix is an effect variable on that arrow, `->{e}`, not the argument's + /// inferred set, which would accept only callbacks performing exactly that. + /// The message names both sides, points at the argument, and shows the + /// declared type with `->{e}` in place. Any other failure keeps its message. + fn closed_callback_hint( + &self, + func: &Expr, + arg: &Expr, + func_ty: &Ty, + arg_ty: &Ty, + err: TypeError, + ) -> TypeError { + if !err.message.starts_with("effect mismatch") { + return err; + } + let Ty::Fun(param, _, _) = self.apply(func_ty) else { + return err; + }; + let given = self.apply(arg_ty); + // Walk the declared callback and the argument arrow by arrow to the first + // closed declared effect the argument's differs from: declared effects are + // exact, so a callback doing less is rejected as surely as one doing more. + let (mut declared, mut actual, mut arrow) = (param.as_ref(), &given, 0); + let (closed, performs) = loop { + match (declared, actual) { + (Ty::Fun(_, d_ret, d_eff), Ty::Fun(_, a_ret, a_eff)) => { + let d_eff = self.apply_eff(d_eff); + let a_eff = self.apply_eff(a_eff); + if d_eff.vars.is_empty() && a_eff.labels != d_eff.labels { + break (d_eff, a_eff); + } + (declared, actual, arrow) = (d_ret, a_ret, arrow + 1); + } + _ => return err, + } + }; + let mut head = func; + while let ExprKind::App { func, .. } = &head.kind { + head = func; + } + let Some(owner) = (match &head.kind { + ExprKind::Var(name) => Some(name.clone()), + _ => qualified_name(head), + }) else { + return err; + }; + let who = match &arg.kind { + ExprKind::Var(name) => format!("`{name}`"), + _ => "this function".to_string(), + }; + let effects = |eff: &Effect| { + if eff.labels.is_empty() { + "nothing".to_string() + } else { + format!("{{{}}}", eff.show_labels()) + } + }; + let allows = if closed.labels.is_empty() { + "which performs nothing".to_string() + } else { + format!("which performs exactly {}", effects(&closed)) + }; + TypeError { + message: format!( + "{who} performs {}, but `{owner}` takes this callback as `{}`, {allows}; \ + to accept a callback whatever it does, give that arrow an effect variable in \ + the extern that declares it: `{}`", + effects(&performs), + show(declared), + show_with_effect_var(¶m, arrow), + ), + span: arg.span(), + } + } + fn bind_pattern( &mut self, pattern: &Pattern, @@ -10041,6 +10125,32 @@ fn show(ty: &Ty) -> String { buf } +/// A function type shown with the effect variable `->{e}` on its arrow number +/// `arrow` (0 is the outermost), as the fix to suggest for a closed callback +/// arrow (#180): `Reader -> Writer ->{e} Async unit`. +fn show_with_effect_var(ty: &Ty, arrow: usize) -> String { + let mut namer = Namer::default(); + let mut out = String::new(); + let mut cur = ty; + let mut i = 0; + while let Ty::Fun(domain, ret, eff) = cur { + show_into(domain, &mut namer, &mut out, true); + if i == arrow { + out.push_str(" ->{e} "); + } else if eff.labels.is_empty() { + out.push_str(" -> "); + } else { + out.push_str(" ->{"); + out.push_str(&eff.show_labels()); + out.push_str("} "); + } + cur = ret; + i += 1; + } + show_into(cur, &mut namer, &mut out, false); + out +} + fn show_into(ty: &Ty, namer: &mut Namer, out: &mut String, atom: bool) { match ty { // An unresolved `num` literal displays as its default, `int`. diff --git a/tests/compile.rs b/tests/compile.rs index 76a1582..57e49ec 100644 --- a/tests/compile.rs +++ b/tests/compile.rs @@ -4283,7 +4283,7 @@ fn e2e_an_async_callback_handed_to_python_runs() { extern type Writer\n\ extern type Server\n\ extern type Bytes\n\ - extern startServer: (Reader -> Writer ->{io, async} Async unit) -> string -> int -> Async Server = asyncio.start_server\n\ + extern startServer: (Reader -> Writer ->{e} Async unit) -> string -> int -> Async Server = asyncio.start_server\n\ extern openConnection: string -> int -> Async (Reader, Writer) = asyncio.open_connection\n\ extern readline: Reader -> Async Bytes = .readline()\n\ extern write: Writer -> Bytes -> unit = .write\n\ diff --git a/tests/typecheck.rs b/tests/typecheck.rs index c1edf20..d517440 100644 --- a/tests/typecheck.rs +++ b/tests/typecheck.rs @@ -2954,6 +2954,62 @@ fn effect_var_extern_accepts_both_pure_and_io_callbacks() { assert!(pyfun::check(&format!("{each}let r = each (fun x -> print x) [1, 2, 3]")).is_ok()); } +#[test] +fn a_closed_callback_arrow_points_at_the_effect_variable() { + // #180: a callback arrow written without `->{e}` is closed, so any handler + // that does something misses it. The error names both sides, sits on the + // argument rather than the whole call, and shows the `->{e}` fix. + let src = "extern type Writer\n\ + extern type Server\n\ + extern closeConn: Writer -> unit = .close()\n\ + extern serve: (Writer -> Async unit) -> int -> Async Server = asyncio.start_server\n\ + let handle w = async { closeConn w }\n\ + let listen = serve handle 7777"; + let errs = pyfun::check(src).unwrap_err(); + assert_eq!( + errs[0].message, + "`handle` performs {io, async}, but `serve` takes this callback as \ + `Writer -> Async unit`, which performs nothing; to accept a callback whatever it \ + does, give that arrow an effect variable in the extern that declares it: \ + `Writer ->{e} Async unit`" + ); + let at = src.rfind("handle").unwrap(); + assert_eq!( + (errs[0].span.start, errs[0].span.end), + (at, at + "handle".len()) + ); + // With the effect variable, the same handler checks. + assert!( + pyfun::check(&src.replace("(Writer -> Async unit)", "(Writer ->{e} Async unit)")).is_ok() + ); +} + +#[test] +fn pasting_the_inferred_set_into_the_callback_still_points_at_the_effect_variable() { + // Declared effects are exact: `->{io, async}` rejects a handler that only + // awaits, and the message says so instead of just "effect mismatch". + let src = "extern type Writer\n\ + extern type Server\n\ + extern serve: (Writer ->{io, async} Async unit) -> int -> Async Server = asyncio.start_server\n\ + let idle w = async { return () }\n\ + let listen = serve idle 7777"; + assert_error_contains(src, "`idle` performs {async}"); + assert_error_contains(src, "which performs exactly {io, async}"); + assert_error_contains(src, "`Writer ->{e} Async unit`"); +} + +#[test] +fn a_callback_of_the_wrong_type_keeps_its_type_mismatch() { + let src = "extern type Writer\n\ + extern type Server\n\ + extern serve: (Writer -> Async unit) -> int -> Async Server = asyncio.start_server\n\ + let listen = serve 5 7777"; + assert_eq!( + errors(src), + ["type mismatch: expected Writer -> Async unit, found int"] + ); +} + #[test] fn pure_pipeline_through_an_effect_var_extern_is_pure() { // With a pure callback the whole pipeline is provably pure: `e` on the callback