Skip to content

Commit 204b210

Browse files
committed
Give "did you mean ...?" hints also for simple identifiers
Fixes #18682 [Cherry-picked f2e2e3f][modified]
1 parent 48f65c9 commit 204b210

13 files changed

+248
-68
lines changed
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package dotty.tools
2+
package dotc
3+
package reporting
4+
5+
import core._
6+
import Contexts._
7+
import Decorators.*, Symbols.*, Names.*, Types.*, Flags.*
8+
import typer.ProtoTypes.{FunProto, SelectionProto}
9+
import transform.SymUtils.isNoValue
10+
11+
/** A utility object to support "did you mean" hinting */
12+
object DidYouMean:
13+
14+
def kindOK(sym: Symbol, isType: Boolean, isApplied: Boolean)(using Context): Boolean =
15+
if isType then sym.isType
16+
else sym.isTerm || isApplied && sym.isClass && !sym.is(ModuleClass)
17+
// also count classes if followed by `(` since they have constructor proxies,
18+
// but these don't show up separately as members
19+
// Note: One need to be careful here not to complete symbols. For instance,
20+
// we run into trouble if we ask whether a symbol is a legal value.
21+
22+
/** The names of all non-synthetic, non-private members of `site`
23+
* that are of the same type/term kind as the missing member.
24+
*/
25+
def memberCandidates(site: Type, isType: Boolean, isApplied: Boolean)(using Context): collection.Set[Symbol] =
26+
for
27+
bc <- site.widen.baseClasses.toSet
28+
sym <- bc.info.decls.filter(sym =>
29+
kindOK(sym, isType, isApplied)
30+
&& !sym.isConstructor
31+
&& !sym.flagsUNSAFE.isOneOf(Synthetic | Private))
32+
yield sym
33+
34+
case class Binding(name: Name, sym: Symbol, site: Type)
35+
36+
/** The name, symbol, and prefix type of all non-synthetic declarations that are
37+
* defined or imported in some enclosing scope and that are of the same type/term
38+
* kind as the missing member.
39+
*/
40+
def inScopeCandidates(isType: Boolean, isApplied: Boolean, rootImportOK: Boolean)(using Context): collection.Set[Binding] =
41+
val acc = collection.mutable.HashSet[Binding]()
42+
def nextInteresting(ctx: Context): Context =
43+
if ctx.outer.isImportContext
44+
|| ctx.outer.scope != ctx.scope
45+
|| ctx.outer.owner.isClass && ctx.outer.owner != ctx.owner
46+
|| (ctx.outer eq NoContext)
47+
then ctx.outer
48+
else nextInteresting(ctx.outer)
49+
50+
def recur()(using Context): Unit =
51+
if ctx eq NoContext then
52+
() // done
53+
else if ctx.isImportContext then
54+
val imp = ctx.importInfo.nn
55+
if imp.isRootImport && !rootImportOK then
56+
() // done
57+
else imp.importSym.info match
58+
case ImportType(expr) =>
59+
val candidates = memberCandidates(expr.tpe, isType, isApplied)
60+
if imp.isWildcardImport then
61+
for cand <- candidates if !imp.excluded.contains(cand.name.toTermName) do
62+
acc += Binding(cand.name, cand, expr.tpe)
63+
for sel <- imp.selectors do
64+
val selStr = sel.name.show
65+
if sel.name == sel.rename then
66+
for cand <- candidates if cand.name.toTermName.show == selStr do
67+
acc += Binding(cand.name, cand, expr.tpe)
68+
else if !sel.isUnimport then
69+
for cand <- candidates if cand.name.toTermName.show == selStr do
70+
acc += Binding(sel.rename.likeSpaced(cand.name), cand, expr.tpe)
71+
case _ =>
72+
recur()(using nextInteresting(ctx))
73+
else
74+
if ctx.owner.isClass then
75+
for sym <- memberCandidates(ctx.owner.typeRef, isType, isApplied) do
76+
acc += Binding(sym.name, sym, ctx.owner.thisType)
77+
else
78+
ctx.scope.foreach: sym =>
79+
if kindOK(sym, isType, isApplied)
80+
&& !sym.isConstructor
81+
&& !sym.flagsUNSAFE.is(Synthetic)
82+
then acc += Binding(sym.name, sym, NoPrefix)
83+
recur()(using nextInteresting(ctx))
84+
end recur
85+
86+
recur()
87+
acc
88+
end inScopeCandidates
89+
90+
/** The Levenshtein distance between two strings */
91+
def distance(s1: String, s2: String): Int =
92+
val dist = Array.ofDim[Int](s2.length + 1, s1.length + 1)
93+
for
94+
j <- 0 to s2.length
95+
i <- 0 to s1.length
96+
do
97+
dist(j)(i) =
98+
if j == 0 then i
99+
else if i == 0 then j
100+
else if s2(j - 1) == s1(i - 1) then dist(j - 1)(i - 1)
101+
else (dist(j - 1)(i) min dist(j)(i - 1) min dist(j - 1)(i - 1)) + 1
102+
dist(s2.length)(s1.length)
103+
104+
/** List of possible candidate names with their Levenstein distances
105+
* to the name `from` of the missing member.
106+
* @param maxDist Maximal number of differences to be considered for a hint
107+
* A distance qualifies if it is at most `maxDist`, shorter than
108+
* the lengths of both the candidate name and the missing member name
109+
* and not greater than half the average of those lengths.
110+
*/
111+
extension [S <: Symbol | Binding](candidates: collection.Set[S])
112+
def closestTo(str: String, maxDist: Int = 3)(using Context): List[(Int, S)] =
113+
def nameStr(cand: S): String = cand match
114+
case sym: Symbol => sym.name.show
115+
case bdg: Binding => bdg.name.show
116+
candidates
117+
.toList
118+
.map(cand => (distance(nameStr(cand), str), cand))
119+
.filter((d, cand) =>
120+
d <= maxDist
121+
&& d * 4 <= str.length + nameStr(cand).length
122+
&& d < str.length
123+
&& d < nameStr(cand).length)
124+
.sortBy((d, cand) => (d, nameStr(cand))) // sort by distance first, alphabetically second
125+
126+
def didYouMean(candidates: List[(Int, Binding)], proto: Type, prefix: String)(using Context): String =
127+
128+
def qualifies(b: Binding)(using Context): Boolean =
129+
proto match
130+
case _: SelectionProto => true
131+
case _ =>
132+
try !b.sym.isNoValue
133+
catch case ex: Exception => false
134+
135+
def showName(name: Name, sym: Symbol)(using Context): String =
136+
if sym.is(ModuleClass) then s"${name.show}.type"
137+
else name.show
138+
139+
def recur(candidates: List[(Int, Binding)]): String = candidates match
140+
case (d, b) :: rest
141+
if d != 0 || b.sym.is(ModuleClass) => // Avoid repeating the same name in "did you mean"
142+
if qualifies(b) then
143+
s" - did you mean $prefix${showName(b.name, b.sym)}?"
144+
else
145+
recur(rest)
146+
case _ => ""
147+
148+
recur(candidates)
149+
end didYouMean
150+
end DidYouMean

compiler/src/dotty/tools/dotc/reporting/messages.scala

Lines changed: 36 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import ErrorMessageID._
1616
import ast.Trees
1717
import config.{Feature, ScalaVersion}
1818
import typer.ErrorReporting.{err, matchReductionAddendum, substitutableTypeSymbolsInScope}
19-
import typer.ProtoTypes.ViewProto
19+
import typer.ProtoTypes.{ViewProto, SelectionProto, FunProto}
2020
import typer.Implicits.*
2121
import typer.Inferencing
2222
import scala.util.control.NonFatal
@@ -34,6 +34,7 @@ import dotty.tools.dotc.util.Spans.Span
3434
import dotty.tools.dotc.util.SourcePosition
3535
import scala.jdk.CollectionConverters.*
3636
import dotty.tools.dotc.util.SourceFile
37+
import DidYouMean.*
3738

3839
/** Messages
3940
* ========
@@ -243,14 +244,29 @@ extends NamingMsg(DuplicateBindID) {
243244
}
244245
}
245246

246-
class MissingIdent(tree: untpd.Ident, treeKind: String, val name: Name)(using Context)
247+
class MissingIdent(tree: untpd.Ident, treeKind: String, val name: Name, proto: Type)(using Context)
247248
extends NotFoundMsg(MissingIdentID) {
248-
def msg(using Context) = i"Not found: $treeKind$name"
249+
def msg(using Context) =
250+
val missing = name.show
251+
val addendum =
252+
didYouMean(
253+
inScopeCandidates(name.isTypeName, isApplied = proto.isInstanceOf[FunProto], rootImportOK = true)
254+
.closestTo(missing),
255+
proto, "")
256+
257+
i"Not found: $treeKind$name$addendum"
249258
def explain(using Context) = {
250-
i"""|The identifier for `$treeKind$name` is not bound, that is,
251-
|no declaration for this identifier can be found.
252-
|That can happen, for example, if `$name` or its declaration has either been
253-
|misspelt or if an import is missing."""
259+
i"""|Each identifier in Scala needs a matching declaration. There are two kinds of
260+
|identifiers: type identifiers and value identifiers. Value identifiers are introduced
261+
|by `val`, `def`, or `object` declarations. Type identifiers are introduced by `type`,
262+
|`class`, or `trait` declarations.
263+
|
264+
|Identifiers refer to matching declarations in their environment, or they can be
265+
|imported from elsewhere.
266+
|
267+
|Possible reasons why no matching declaration was found:
268+
| - The declaration or the use is mis-spelt.
269+
| - An import is missing."""
254270
}
255271
}
256272

@@ -309,48 +325,13 @@ class TypeMismatch(found: Type, expected: Type, inTree: Option[untpd.Tree], adde
309325

310326
end TypeMismatch
311327

312-
class NotAMember(site: Type, val name: Name, selected: String, addendum: => String = "")(using Context)
328+
class NotAMember(site: Type, val name: Name, selected: String, proto: Type, addendum: => String = "")(using Context)
313329
extends NotFoundMsg(NotAMemberID), ShowMatchTrace(site) {
314330
//println(i"site = $site, decls = ${site.decls}, source = ${site.typeSymbol.sourceFile}") //DEBUG
315331

316332
def msg(using Context) = {
317-
import core.Flags._
318-
val maxDist = 3 // maximal number of differences to be considered for a hint
319333
val missing = name.show
320334

321-
// The symbols of all non-synthetic, non-private members of `site`
322-
// that are of the same type/term kind as the missing member.
323-
def candidates: Set[Symbol] =
324-
for
325-
bc <- site.widen.baseClasses.toSet
326-
sym <- bc.info.decls.filter(sym =>
327-
sym.isType == name.isTypeName
328-
&& !sym.isConstructor
329-
&& !sym.flagsUNSAFE.isOneOf(Synthetic | Private))
330-
yield sym
331-
332-
// Calculate Levenshtein distance
333-
def distance(s1: String, s2: String): Int =
334-
val dist = Array.ofDim[Int](s2.length + 1, s1.length + 1)
335-
for
336-
j <- 0 to s2.length
337-
i <- 0 to s1.length
338-
do
339-
dist(j)(i) =
340-
if j == 0 then i
341-
else if i == 0 then j
342-
else if s2(j - 1) == s1(i - 1) then dist(j - 1)(i - 1)
343-
else (dist(j - 1)(i) min dist(j)(i - 1) min dist(j - 1)(i - 1)) + 1
344-
dist(s2.length)(s1.length)
345-
346-
// A list of possible candidate symbols with their Levenstein distances
347-
// to the name of the missing member
348-
def closest: List[(Int, Symbol)] = candidates
349-
.toList
350-
.map(sym => (distance(sym.name.show, missing), sym))
351-
.filter((d, sym) => d <= maxDist && d < missing.length && d < sym.name.show.length)
352-
.sortBy((d, sym) => (d, sym.name.show)) // sort by distance first, alphabetically second
353-
354335
val enumClause =
355336
if ((name eq nme.values) || (name eq nme.valueOf)) && site.classSymbol.companionClass.isEnumClass then
356337
val kind = if name eq nme.values then i"${nme.values} array" else i"${nme.valueOf} lookup method"
@@ -367,17 +348,18 @@ extends NotFoundMsg(NotAMemberID), ShowMatchTrace(site) {
367348

368349
val finalAddendum =
369350
if addendum.nonEmpty then prefixEnumClause(addendum)
370-
else closest match
371-
case (d, sym) :: _ =>
372-
val siteName = site match
373-
case site: NamedType => site.name.show
374-
case site => i"$site"
375-
val showName =
376-
// Add .type to the name if it is a module
377-
if sym.is(ModuleClass) then s"${sym.name.show}.type"
378-
else sym.name.show
379-
s" - did you mean $siteName.$showName?$enumClause"
380-
case Nil => prefixEnumClause("")
351+
else
352+
val hint = didYouMean(
353+
memberCandidates(site, name.isTypeName, isApplied = proto.isInstanceOf[FunProto])
354+
.closestTo(missing)
355+
.map((d, sym) => (d, Binding(sym.name, sym, site))),
356+
proto,
357+
prefix = site match
358+
case site: NamedType => i"${site.name}."
359+
case site => i"$site."
360+
)
361+
if hint.isEmpty then prefixEnumClause("")
362+
else hint ++ enumClause
381363

382364
i"$selected $name is not a member of ${site.widen}$finalAddendum"
383365
}

compiler/src/dotty/tools/dotc/typer/Checking.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1540,7 +1540,7 @@ trait Checking {
15401540
&& !qualType.member(sel.name).exists
15411541
&& !qualType.member(sel.name.toTypeName).exists
15421542
then
1543-
report.error(NotAMember(qualType, sel.name, "value"), sel.imported.srcPos)
1543+
report.error(NotAMember(qualType, sel.name, "value", WildcardType), sel.imported.srcPos)
15441544
if seen.contains(sel.name) then
15451545
report.error(ImportRenamedTwice(sel.imported), sel.imported.srcPos)
15461546
seen += sel.name

compiler/src/dotty/tools/dotc/typer/TypeAssigner.scala

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ trait TypeAssigner {
161161

162162
def importSuggestionAddendum(pt: Type)(using Context): String = ""
163163

164-
def notAMemberErrorType(tree: untpd.Select, qual: Tree)(using Context): ErrorType =
164+
def notAMemberErrorType(tree: untpd.Select, qual: Tree, proto: Type)(using Context): ErrorType =
165165
val qualType = qual.tpe.widenIfUnstable
166166
def kind = if tree.isType then "type" else "value"
167167
val foundWithoutNull = qualType match
@@ -173,7 +173,7 @@ trait TypeAssigner {
173173
def addendum = err.selectErrorAddendum(tree, qual, qualType, importSuggestionAddendum, foundWithoutNull)
174174
val msg: Message =
175175
if tree.name == nme.CONSTRUCTOR then em"$qualType does not have a constructor"
176-
else NotAMember(qualType, tree.name, kind, addendum)
176+
else NotAMember(qualType, tree.name, kind, proto, addendum)
177177
errorType(msg, tree.srcPos)
178178

179179
def inaccessibleErrorType(tpe: NamedType, superAccess: Boolean, pos: SrcPos)(using Context): Type =
@@ -202,7 +202,7 @@ trait TypeAssigner {
202202
def assignType(tree: untpd.Select, qual: Tree)(using Context): Select =
203203
val rawType = selectionType(tree, qual)
204204
val checkedType = ensureAccessible(rawType, qual.isInstanceOf[Super], tree.srcPos)
205-
val ownType = checkedType.orElse(notAMemberErrorType(tree, qual))
205+
val ownType = checkedType.orElse(notAMemberErrorType(tree, qual, WildcardType))
206206
assignType(tree, ownType)
207207

208208
/** Normalize type T appearing in a new T by following eta expansions to

compiler/src/dotty/tools/dotc/typer/Typer.scala

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -656,7 +656,7 @@ class Typer(@constructorOnly nestingLevel: Int = 0) extends Namer
656656
then // we are in the arguments of a this(...) constructor call
657657
errorTree(tree, em"$tree is not accessible from constructor arguments")
658658
else
659-
errorTree(tree, MissingIdent(tree, kind, name))
659+
errorTree(tree, MissingIdent(tree, kind, name, pt))
660660
end typedIdent
661661

662662
/** (1) If this reference is neither applied nor selected, check that it does
@@ -745,7 +745,7 @@ class Typer(@constructorOnly nestingLevel: Int = 0) extends Namer
745745
case rawType: NamedType =>
746746
inaccessibleErrorType(rawType, superAccess, tree.srcPos)
747747
case _ =>
748-
notAMemberErrorType(tree, qual))
748+
notAMemberErrorType(tree, qual, pt))
749749
end typedSelect
750750

751751
def typedSelect(tree: untpd.Select, pt: Type)(using Context): Tree = {

tests/neg-macros/i15009a.check

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,6 @@
3131
-- [E006] Not Found Error: tests/neg-macros/i15009a.scala:12:2 ---------------------------------------------------------
3232
12 | $int // error: Not found: $int
3333
| ^^^^
34-
| Not found: $int
34+
| Not found: $int - did you mean int?
3535
|
3636
| longer explanation available when compiling with `-explain`

tests/neg/i13320.check

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,4 @@
99
-- [E008] Not Found Error: tests/neg/i13320.scala:4:22 -----------------------------------------------------------------
1010
4 |var x: Foo.Booo = Foo.Booo // error // error
1111
| ^^^^^^^^
12-
| value Booo is not a member of object Foo - did you mean Foo.Boo?
12+
| value Booo is not a member of object Foo - did you mean Foo.Boo?

tests/neg/i16653.check

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
-- [E006] Not Found Error: tests/neg/i16653.scala:1:7 ------------------------------------------------------------------
22
1 |import demo.implicits._ // error
33
| ^^^^
4-
| Not found: demo
4+
| Not found: demo - did you mean Demo?
55
|
66
| longer explanation available when compiling with `-explain`

tests/neg/i18682.check

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
-- [E006] Not Found Error: tests/neg/i18682.scala:3:8 ------------------------------------------------------------------
2+
3 |val _ = Fop(1) // error
3+
| ^^^
4+
| Not found: Fop - did you mean Foo?
5+
|
6+
| longer explanation available when compiling with `-explain`
7+
-- [E006] Not Found Error: tests/neg/i18682.scala:4:12 -----------------------------------------------------------------
8+
4 |val _ = new Fooo(2) // error
9+
| ^^^^
10+
| Not found: type Fooo - did you mean Foo?
11+
|
12+
| longer explanation available when compiling with `-explain`
13+
-- [E006] Not Found Error: tests/neg/i18682.scala:6:8 ------------------------------------------------------------------
14+
6 |val _ = hellx // error
15+
| ^^^^^
16+
| Not found: hellx - did you mean hello?
17+
|
18+
| longer explanation available when compiling with `-explain`
19+
-- [E008] Not Found Error: tests/neg/i18682.scala:13:12 ----------------------------------------------------------------
20+
13 |val _ = bar.Bap // error, App does shown as hint, too far away
21+
| ^^^^^^^
22+
| value Bap is not a member of object Bar
23+
-- [E008] Not Found Error: tests/neg/i18682.scala:14:12 ----------------------------------------------------------------
24+
14 |val _ = bar.Bap() // error
25+
| ^^^^^^^
26+
| value Bap is not a member of object Bar - did you mean bar.Baz?
27+
-- [E006] Not Found Error: tests/neg/i18682.scala:16:8 -----------------------------------------------------------------
28+
16 |val _ = error // error, java.lang.Error does not show as hint, since it is not a value
29+
| ^^^^^
30+
| Not found: error
31+
|
32+
| longer explanation available when compiling with `-explain`

0 commit comments

Comments
 (0)