Skip to content

Commit 80c481d

Browse files
jiribenesFlat
authored andcommitted
Add 'did you mean' help for mistaken type names (#1162)
Add a system to help the users when the compiler cannot find a name of a type: #### Heuristic 1: Some types are named differently in Effekt than in other languages <img width="469" height="100" alt="Screenshot 2025-10-27 at 15 06 41" src="https://github.com/user-attachments/assets/8b2a4a64-1860-4e5c-b1ec-05b9c3ecc609" /> This specific one (`Boolean` instead of `Bool`) is done often by students and LLMs alike. #### Heuristic 2: Try case-insensitive comparison <img width="390" height="137" alt="Screenshot 2025-10-27 at 15 06 26" src="https://github.com/user-attachments/assets/893777c5-247c-486f-8a94-704c740fe69d" /> #### Heuristic 3: Try full on edit distance! <img width="359" height="135" alt="Screenshot 2025-10-27 at 22 31 43" src="https://github.com/user-attachments/assets/9570b6ef-2222-4132-9575-e8773edf8f3b" />
1 parent 2c3e292 commit 80c481d

10 files changed

+154
-1
lines changed

effekt/shared/src/main/scala/effekt/symbols/Scope.scala

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,8 +208,61 @@ object scopes {
208208
else syms.head
209209
}}
210210

211+
// TODO: Use this for more than just types?
212+
//
213+
// NOTE: We might also want to generalize to return more possible candidates than one
214+
// (1 works fine for types, but maybe not for fields/methods/functions/...)
215+
private def didYouMean(nameNotFound: String, candidates: => Set[String], specificHeuristic: String => Option[String] = _ => None)(using E: ErrorReporter): Unit =
216+
// Priority 1: A specific heuristic
217+
specificHeuristic(nameNotFound) orElse {
218+
// Priority 2: Exact case-insensitive match
219+
candidates
220+
.find { name => name.toUpperCase == nameNotFound.toUpperCase }
221+
.map { exactMatch => pp"Did you mean $exactMatch?" }
222+
.orElse {
223+
// Priority 3: Edit distance
224+
val threshold = ((nameNotFound.length + 2) max 3) / 3
225+
226+
candidates
227+
.toSeq
228+
.flatMap { candidate =>
229+
effekt.util.editDistance(nameNotFound, candidate, threshold).map((_, candidate))
230+
}
231+
.sorted
232+
.headOption
233+
.map { case (_, name) => pp"Did you mean $name?" }
234+
}
235+
} foreach { msg => E.info(msg) }
236+
237+
// NOTE: Most of these should be covered by the edit distance anyway...
238+
private def didYouMeanTypeHeuristic(name: String): Option[String] = name match {
239+
case "Boolean" | "boolean" => Some(pp"Did you mean Bool?")
240+
case "Str" | "str" => Some(pp"Did you mean String?")
241+
case "Integer" | "Int32" | "Int64" | "I32" | "I64" | "i32" | "i64" =>
242+
Some(pp"Did you mean Int?")
243+
case "UInt32" | "UInt64" | "UInt" | "U32" | "U64" | "u32" | "u64" =>
244+
Some(pp"Effekt only supports signed integers, did you mean Int?")
245+
case "Int8" | "UInt8" | "I8" | "U8" | "i8" | "u8" =>
246+
Some(pp"Did you mean Byte (8 bits) or Char (32 bits)?")
247+
case "Float" | "float" | "F64" | "F32" | "f64" | "f32" =>
248+
Some(pp"Effekt only supports 64bit floating numbers, did you mean Double?")
249+
case "Maybe" | "maybe" => Some(pp"Did you mean Option?")
250+
case "Void" | "void" => Some(pp"Did you mean Unit?")
251+
case _ => None
252+
}
253+
211254
def lookupType(id: IdRef)(using E: ErrorReporter): TypeSymbol =
212-
lookupTypeOption(id.path, id.name) getOrElse { E.abort(pp"Could not resolve type ${id}") }
255+
lookupTypeOption(id.path, id.name) getOrElse {
256+
// If we got here, we could not find an exact match.
257+
// But let's try and find a close one!
258+
def availableTypes = all(id.path, scope) {
259+
_.types.keys.toList
260+
}.flatten.toSet
261+
262+
didYouMean(id.name, availableTypes, didYouMeanTypeHeuristic)
263+
264+
E.abort(pp"Could not resolve type $id")
265+
}
213266

214267
def lookupTypeOption(path: List[String], name: String)(using E: ErrorReporter): Option[TypeSymbol] =
215268
first(path, scope) { _.types.get(name) }
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package effekt.util
2+
3+
def editDistance(a: String, b: String, limit: Int): Option[Int] = {
4+
val n = a.length
5+
val m = b.length
6+
if (n == 0) return Some(m)
7+
if (m == 0) return Some(n)
8+
9+
// Early exit if minimum distance exceeds limit
10+
val minDist = Math.abs(n - m)
11+
if (minDist > limit) return None
12+
13+
// Strip common prefix
14+
val prefixLen = a.view.zip(b.view).takeWhile { case (a, b) => a == b }.size
15+
16+
// Strip common suffix (but don't overlap with prefix)
17+
val suffixLen = a.view.drop(prefixLen).reverse
18+
.zip(b.view.drop(prefixLen).reverse)
19+
.takeWhile { case (a, b) => a == b }
20+
.size
21+
22+
val s1 = a.substring(prefixLen, n - suffixLen)
23+
val s2 = b.substring(prefixLen, m - suffixLen)
24+
25+
// After stripping, if one is empty, distance is just the other's length
26+
if (s1.isEmpty) return Some(s2.length)
27+
if (s2.isEmpty) return Some(s1.length)
28+
29+
val distance = levenshtein(s1, s2)
30+
if (distance <= limit) Some(distance) else None
31+
}
32+
33+
inline def levenshtein(s1: String, s2: String): Int = {
34+
val n = s1.length
35+
val m = s2.length
36+
37+
val d = Array.ofDim[Int](n + 1, m + 1)
38+
for (i <- 0 to n) { d(i)(0) = i }
39+
for (j <- 0 to m) { d(0)(j) = j }
40+
41+
for {
42+
i <- 1 to n; s1_i = s1(i - 1)
43+
j <- 1 to m; s2_j = s2(j - 1)
44+
} do {
45+
val costDelete = d(i - 1)(j) + 1
46+
val costInsert = d(i)(j - 1) + 1
47+
val costSubstitute = d(i - 1)(j - 1) + (if (s1_i == s2_j) 0 else 1)
48+
49+
d(i)(j) = costDelete min costInsert min costSubstitute
50+
51+
// Transposition: swap adjacent characters (Bolo ~> Bool)
52+
if (i > 1 && j > 1 && s1(i - 1) == s2(j - 2) && s1(i - 2) == s2(j - 1)) {
53+
d(i)(j) = d(i)(j) min (d(i - 2)(j - 2) + 1)
54+
}
55+
}
56+
57+
d(n)(m)
58+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[info] examples/neg/boolean_wrong_name.effekt:3:19: Did you mean Bool?
2+
def alwaysTrue(): Boolean = true
3+
^^^^^^^
4+
[error] examples/neg/boolean_wrong_name.effekt:3:19: Could not resolve type Boolean
5+
def alwaysTrue(): Boolean = true
6+
^^^^^^^
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module boolean_wrong_name
2+
3+
def alwaysTrue(): Boolean = true
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[info] examples/neg/case_insensitive_type_hint.effekt:5:12: Did you mean MyType?
2+
def fun(): Mytype = MyType()
3+
^^^^^^
4+
[error] examples/neg/case_insensitive_type_hint.effekt:5:12: Could not resolve type Mytype
5+
def fun(): Mytype = MyType()
6+
^^^^^^
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module case_insensitive_type_hint
2+
3+
record MyType()
4+
5+
def fun(): Mytype = MyType()
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[info] examples/neg/case_insensitive_type_hint2.effekt:5:12: Did you mean Mytype?
2+
def fun(): MyType = Mytype()
3+
^^^^^^
4+
[error] examples/neg/case_insensitive_type_hint2.effekt:5:12: Could not resolve type MyType
5+
def fun(): MyType = Mytype()
6+
^^^^^^
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module case_insensitive_type_hint2
2+
3+
record Mytype()
4+
5+
def fun(): MyType = Mytype()

examples/pos/boolean_custom_type_no_error.check

Whitespace-only changes.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
module boolean_custom_type_no_error
2+
3+
// see neg/boolean_wrong_name
4+
// Here we define our own 'Boolean' type,
5+
// so that the error "please try 'Bool' instead" never pops up
6+
7+
record Boolean(b: Bool)
8+
9+
def alwaysTrue(): Boolean = Boolean(true)
10+
11+
def main() = ()

0 commit comments

Comments
 (0)