Skip to content

Commit 8e350df

Browse files
authored
Nicer error messages on argument list / tuple mismatch (#1225)
Resolves #668 Continues on from #1205 by having a much more structured representation: instead of just numbers, we actually `align` the two argument lists, seeing what's in both, what's only expected, and what's only received. Then once we have the information, we can (very hackily!) resolve #668 by providing slightly more specialised messages when argument lists and tuples are mismatched. Note that this message is only shown somewhat conservatively: only if the swap for a tuple could work based on the arg count! <img width="779" height="183" alt="Screenshot 2025-11-27 at 16 11 49" src="https://github.com/user-attachments/assets/4575de71-32cf-4a8c-91f3-d03cbdddac8a" /> <img width="785" height="181" alt="Screenshot 2025-11-27 at 16 12 37" src="https://github.com/user-attachments/assets/052334c1-91c8-43cc-9a4e-738e87f483e2" /> <img width="878" height="164" alt="Screenshot 2025-11-27 at 16 12 16" src="https://github.com/user-attachments/assets/e0a248aa-e81c-4a42-b4f0-4e1b972526f9" /> <img width="873" height="160" alt="Screenshot 2025-11-27 at 16 46 54" src="https://github.com/user-attachments/assets/43d4d3b2-0fd6-46af-80af-568b755a9fc5" /> I think that all of these could eventually have quick fixes attached to them :)
1 parent 85d81cf commit 8e350df

10 files changed

+159
-43
lines changed

effekt/shared/src/main/scala/effekt/Parser.scala

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1142,8 +1142,9 @@ class Parser(tokens: Seq[Token], source: Source) {
11421142
braces {
11431143
peek.kind match {
11441144
// { case ... => ... }
1145-
case `case` => someWhile(matchClause(), `case`) match { case cs =>
1145+
case `case` =>
11461146
nonterminal:
1147+
val cs = someWhile(matchClause(), `case`)
11471148
val argSpans = cs match {
11481149
case Many(MatchClause(MultiPattern(ps, _), _, _, _) :: _, _) => ps.map(_.span)
11491150
case p => List(p.span)
@@ -1164,7 +1165,7 @@ class Parser(tokens: Seq[Token], source: Source) {
11641165
), span().synthesized),
11651166
span().synthesized
11661167
)
1167-
}
1168+
11681169
case _ =>
11691170
// { (x: Int) => ... }
11701171
nonterminal:

effekt/shared/src/main/scala/effekt/Typer.scala

Lines changed: 112 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -977,7 +977,7 @@ object Typer extends Phase[NameResolved, Typechecked] {
977977
val bt @ FunctionType(tps, cps, vps, bps, tpe1, effs) = expected
978978

979979
// (2) Check wellformedness (that type, value, block params and args align)
980-
assertArgsParamsAlign("function", tparams.size, vparams.size, bparams.size, tps.size, vps.size, bps.size)
980+
assertArgsParamsAlign(name = None, Aligned(tparams, tps), Aligned(vparams, vps), Aligned(bparams, bps))
981981

982982
// (3) Substitute type parameters
983983
val typeParams = tparams.map { p => p.symbol.asTypeParam }
@@ -1270,63 +1270,134 @@ object Typer extends Phase[NameResolved, Typechecked] {
12701270
}
12711271
}
12721272

1273+
/** Result of trying to align 'got' and 'expected' with matched pairs and mismatches */
1274+
case class Aligned[+A, +B](
1275+
matched: List[(A, B)], /// got and expected paired up
1276+
extra: List[A], /// got but not expected
1277+
missing: List[B] /// expected but not got
1278+
) {
1279+
def isAligned: Boolean = extra.isEmpty && missing.isEmpty
1280+
1281+
def gotCount: Int = matched.size + extra.size
1282+
def expectedCount: Int = matched.size + missing.size
1283+
def delta: Int = extra.size - missing.size // > 0 => too many
1284+
}
1285+
1286+
object Aligned {
1287+
def apply[A, B](got: List[A], expected: List[B]): Aligned[A, B] = {
1288+
@scala.annotation.tailrec
1289+
def loop(got: List[A], expected: List[B], matched: List[(A, B)]): Aligned[A, B] =
1290+
(got, expected) match {
1291+
case (Nil, Nil) => Aligned(matched.reverse, Nil, Nil)
1292+
case (extra, Nil) => Aligned(matched.reverse, extra, Nil)
1293+
case (Nil, missing) => Aligned(matched.reverse, Nil, missing)
1294+
case (g :: gs, e :: es) => loop(gs, es, (g, e) :: matched)
1295+
}
1296+
1297+
loop(got, expected, Nil)
1298+
}
1299+
}
1300+
12731301
/**
12741302
* Asserts that number of {type, value, block} arguments is the same as
12751303
* the number of {type, value, block} parameters.
12761304
* If not, aborts the context with a nice error message.
1305+
* Also tries to add 'did you mean' context for the user on common errors.
1306+
*
1307+
* @param name None if it's a block literal, otherwise the expected name
12771308
*/
12781309
private def assertArgsParamsAlign(
1279-
name: String,
1280-
gotTypes: Int, gotValues: Int, gotBlocks: Int,
1281-
expectedTypes: Int, expectedValues: Int, expectedBlocks: Int
1282-
)(using Context): Unit = {
1310+
name: Option[String],
1311+
types: Aligned[source.Id | ValueType, TypeParam],
1312+
values: Aligned[source.ValueParam | source.ValueArg, ValueType],
1313+
blocks: Aligned[source.BlockParam | source.Term, BlockType]
1314+
)(using Context): Unit = {
12831315

1284-
val targsOk = gotTypes == 0 || gotTypes == expectedTypes
1285-
val vargsOk = gotValues == expectedValues
1286-
val bargsOk = gotBlocks == expectedBlocks
1316+
// Type args are OK iff nothing provided or perfectly aligned
1317+
val typesOk = types.gotCount == 0 || types.isAligned
12871318

12881319
def pluralized(n: Int, singular: String): String =
12891320
if (n == 1) s"$n $singular" else s"$n ${singular}s"
12901321

1291-
def formatArgs(types: Option[Int], values: Option[Int], blocks: Option[Int]): String = {
1292-
val parts = List(
1293-
types.map { pluralized(_, "type argument") },
1294-
values.map { pluralized(_, "value argument") },
1295-
blocks.map { pluralized(_, "block argument") }
1296-
).flatten
1297-
1298-
parts match {
1299-
case Nil => "no arguments"
1300-
case single :: Nil => single
1301-
case init :+ last => init.mkString(", ") + " and " + last
1302-
case _ => parts.mkString(", ")
1322+
// Hint: Tuple vs arg-list confusion (also covers lambda case)
1323+
if (!values.isAligned) {
1324+
// 1. User wrote `case (x, y) => ...` but function expects multiple arguments
1325+
// => Did we match exactly 1 value param with `__arg... ` name, and have multiple args missing
1326+
// HACK: Hardcoded '__arg' to recognize lambda case
1327+
(values.matched, values.extra, values.missing) match {
1328+
case (List((param: source.ValueParam, _)), Nil, _ :: _)
1329+
if param.id.name.startsWith("__arg") =>
1330+
Context.info(pretty"Did you mean to use `(x, y) => ...` instead of `case (x, y) => ...`?")
1331+
case _ => ()
13031332
}
1304-
}
13051333

1306-
val expected = formatArgs(
1307-
Option.when(!targsOk) { expectedTypes },
1308-
Option.when(!vargsOk) { expectedValues },
1309-
Option.when(!bargsOk) { expectedBlocks }
1310-
)
1311-
val got = formatArgs(
1312-
Option.when(!targsOk) { gotTypes },
1313-
Option.when(!vargsOk) { gotValues },
1314-
Option.when(!bargsOk) { gotBlocks }
1315-
)
1334+
// 2a. User wrote `(x, y) => ... ` but function expects a single tuple
1335+
// 2b. User wrote `foo(x, y)` but the function expects a single tuple
1336+
// => Multiple extra value args, exactly 1 missing that's a tuple matching the count
1337+
// HACK: Hardcoded "tuple is a type whose name starts with 'Tuple'"
1338+
(values.matched, values.extra, values.missing) match {
1339+
case (List((_, tupleTpe @ ValueTypeApp(TypeConstructor.Record(tupleName, _, _, _), args))), extras, Nil)
1340+
if tupleName.name.startsWith("Tuple") && args.size == 1 + extras.size =>
1341+
name match {
1342+
case None => Context.info(pretty"Did you mean to use `case (x, y) => ...` to pattern match on the tuple ${tupleTpe}?")
1343+
case Some(givenName) => Context.info(pretty"Did you mean to call ${givenName} with a single tuple argument instead of separate arguments?")
1344+
}
1345+
case _ => ()
1346+
}
1347+
1348+
// 3. User wrote `foo((x, y))` (tuple literal) but function expects multiple arguments
1349+
// => Single matched arg that's a Call to a Tuple constructor, with some missing params
1350+
// HACK: Hardcoded "tuple constructor is IdRef to TupleN in effekt namespace"
1351+
(values.matched, values.extra, values.missing) match {
1352+
case (List((arg: source.ValueArg, _)), Nil, missing@(_ :: _)) =>
1353+
(arg.value, name) match {
1354+
case (source.Call(source.IdTarget(source.IdRef(List("effekt"), tupleName, _)), _, tupleArgs, _, _), Some(givenName))
1355+
if tupleName.startsWith("Tuple") && tupleArgs.size == 1 + missing.size =>
1356+
Context.info(pretty"Did you mean to call ${givenName} with ${tupleArgs.size} separate arguments instead of a tuple?")
1357+
case _ => ()
1358+
}
1359+
case _ => ()
1360+
}
1361+
}
13161362

1317-
if (!vargsOk && !bargsOk && gotValues + gotBlocks == expectedValues + expectedBlocks) {
1363+
// Hint: Value vs block argument confusion
1364+
if (!values.isAligned && !blocks.isAligned && values.delta + blocks.delta == 0) {
13181365
// If total counts match, but individual do not, it's likely a value vs computation issue
1319-
if (gotBlocks > expectedBlocks) {
1320-
val diff = gotBlocks - expectedBlocks
1321-
Context.info(pretty"Did you mean to pass ${pluralized(diff, "block argument")} as a value? e.g. box it using `box { ... }`")
1322-
} else if (gotValues > expectedValues) {
1323-
val diff = gotValues - expectedValues
1324-
Context.info(pretty"Did you mean to pass ${pluralized(diff, "value argument")} as a block (computation)?")
1366+
if (blocks.delta > 0) {
1367+
Context.info(pretty"Did you mean to pass ${pluralized(blocks.delta, "block argument")} as a value? e.g. box it using `box { ... }`")
1368+
} else if (values.delta > 0) {
1369+
Context.info(pretty"Did you mean to pass ${pluralized(values.delta, "value argument")} as a block (computation)? ")
13251370
}
13261371
}
13271372

1328-
if (!targsOk || !vargsOk || !bargsOk) {
1329-
Context.abort(s"Wrong number of arguments to ${name}: expected ${expected}, but got ${got}")
1373+
if (!typesOk || !values.isAligned || !blocks.isAligned) {
1374+
def formatArgs(types: Option[Int], values: Option[Int], blocks: Option[Int]): String = {
1375+
val parts = List(
1376+
types.map { pluralized(_, "type argument") },
1377+
values.map { pluralized(_, "value argument") },
1378+
blocks.map { pluralized(_, "block argument") }
1379+
).flatten
1380+
1381+
parts match {
1382+
case Nil => "no arguments"
1383+
case single :: Nil => single
1384+
case init :+ last => init.mkString(", ") + " and " + last
1385+
case _ => parts.mkString(", ")
1386+
}
1387+
}
1388+
1389+
val expected = formatArgs(
1390+
Option.when(!typesOk) { types.expectedCount },
1391+
Option.when(!values.isAligned) { values.expectedCount },
1392+
Option.when(!blocks.isAligned) { blocks.expectedCount }
1393+
)
1394+
val got = formatArgs(
1395+
Option.when(!typesOk) { types.gotCount },
1396+
Option.when(!values.isAligned) { values.gotCount },
1397+
Option.when(!blocks.isAligned) { blocks.gotCount }
1398+
)
1399+
1400+
Context.abort(s"Wrong number of arguments to ${name getOrElse "function"}: expected ${expected}, but got ${got}")
13301401
}
13311402
}
13321403

@@ -1343,7 +1414,7 @@ object Typer extends Phase[NameResolved, Typechecked] {
13431414
val callsite = currentCapture
13441415

13451416
// (0) Check that arg & param counts align
1346-
assertArgsParamsAlign(name, targs.size, vargs.size, bargs.size, funTpe.tparams.size, funTpe.vparams.size, funTpe.bparams.size)
1417+
assertArgsParamsAlign(name = Some(name), Aligned(targs, funTpe.tparams), Aligned(vargs, funTpe.vparams), Aligned(bargs, funTpe.bparams))
13471418

13481419
// (1) Instantiate blocktype
13491420
// e.g. `[A, B] (A, A) => B` becomes `(?A, ?A) => ?B`
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[error] examples/neg/typer/expected_args_got_lambdacase.effekt:3:20: Wrong number of arguments to function: expected 2 value arguments, but got 1 value argument
2+
def main() = foo { case (n, s) =>
3+
^
4+
[info] examples/neg/typer/expected_args_got_lambdacase.effekt:3:20: Did you mean to use `(x, y) => ...` instead of `case (x, y) => ...`?
5+
def main() = foo { case (n, s) =>
6+
^
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
def foo { f: (Int, String) => Unit }: Unit = f(42, "hello")
2+
3+
def main() = foo { case (n, s) =>
4+
println(n)
5+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[error] examples/neg/typer/expected_args_got_tuple.effekt:4:11: Wrong number of arguments to foo: expected 2 value arguments, but got 1 value argument
2+
val _ = foo((42, "hello"))
3+
^^^^^^^^^^^^^^^^^^
4+
[info] examples/neg/typer/expected_args_got_tuple.effekt:4:11: Did you mean to call foo with 2 separate arguments instead of a tuple?
5+
val _ = foo((42, "hello"))
6+
^^^^^^^^^^^^^^^^^^
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
def foo(x: Int, s: String): Int = 42
2+
3+
def main() = {
4+
val _ = foo((42, "hello"))
5+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[error] examples/neg/typer/expected_lambdacase_got_args.effekt:3:20: Wrong number of arguments to function: expected 1 value argument, but got 2 value arguments
2+
def main() = foo { (n, s) =>
3+
^
4+
[info] examples/neg/typer/expected_lambdacase_got_args.effekt:3:20: Did you mean to use `case (x, y) => ...` to pattern match on the tuple Tuple2[Int, String]?
5+
def main() = foo { (n, s) =>
6+
^
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
def foo { f: ((Int, String)) => Unit }: Unit = f((42, "hello"))
2+
3+
def main() = foo { (n, s) =>
4+
println(n)
5+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[error] examples/neg/typer/expected_tuple_got_args.effekt:4:11: Wrong number of arguments to foo: expected 1 value argument, but got 2 value arguments
2+
val _ = foo(42, "hello")
3+
^^^^^^^^^^^^^^^^
4+
[info] examples/neg/typer/expected_tuple_got_args.effekt:4:11: Did you mean to call foo with a single tuple argument instead of separate arguments?
5+
val _ = foo(42, "hello")
6+
^^^^^^^^^^^^^^^^
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
def foo(x: (Int, String)): Int = 42
2+
3+
def main() = {
4+
val _ = foo(42, "hello")
5+
}

0 commit comments

Comments
 (0)