Skip to content

Commit 8046a8b

Browse files
authored
Give "did you mean ...?" hints also for simple identifiers (#18747)
Fixes #18682 Fixes #17067
2 parents 2cf4ac3 + 094c7aa commit 8046a8b

13 files changed

+293
-68
lines changed
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
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+
try
130+
val valueOK = proto match
131+
case _: SelectionProto => true
132+
case _ => !b.sym.isNoValue
133+
val accessOK = b.sym.isAccessibleFrom(b.site)
134+
valueOK && accessOK
135+
catch case ex: Exception => false
136+
// exceptions might arise when completing (e.g. malformed class file, or cyclic reference)
137+
138+
def showName(name: Name, sym: Symbol)(using Context): String =
139+
if sym.is(ModuleClass) then s"${name.show}.type"
140+
else name.show
141+
142+
def alternatives(distance: Int, candidates: List[(Int, Binding)]): List[Binding] = candidates match
143+
case (d, b) :: rest if d == distance =>
144+
if qualifies(b) then b :: alternatives(distance, rest) else alternatives(distance, rest)
145+
case _ =>
146+
Nil
147+
148+
def recur(candidates: List[(Int, Binding)]): String = candidates match
149+
case (d, b) :: rest
150+
if d != 0 || b.sym.is(ModuleClass) => // Avoid repeating the same name in "did you mean"
151+
if qualifies(b) then
152+
def hint(b: Binding) = prefix ++ showName(b.name, b.sym)
153+
val alts = alternatives(d, rest).map(hint).take(3)
154+
val suffix = if alts.isEmpty then "" else alts.mkString(" or perhaps ", " or ", "?")
155+
s" - did you mean ${hint(b)}?$suffix"
156+
else
157+
recur(rest)
158+
case _ => ""
159+
160+
recur(candidates)
161+
end didYouMean
162+
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`, `enum`, 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
@@ -1562,7 +1562,7 @@ trait Checking {
15621562
&& !qualType.member(sel.name).exists
15631563
&& !qualType.member(sel.name.toTypeName).exists
15641564
then
1565-
report.error(NotAMember(qualType, sel.name, "value"), sel.imported.srcPos)
1565+
report.error(NotAMember(qualType, sel.name, "value", WildcardType), sel.imported.srcPos)
15661566
if sel.isUnimport then
15671567
if originals.contains(sel.name) then
15681568
report.error(UnimportedAndImported(sel.name, targets.contains(sel.name)), sel.imported.srcPos)

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

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

166166
def importSuggestionAddendum(pt: Type)(using Context): String = ""
167167

168-
def notAMemberErrorType(tree: untpd.Select, qual: Tree)(using Context): ErrorType =
168+
def notAMemberErrorType(tree: untpd.Select, qual: Tree, proto: Type)(using Context): ErrorType =
169169
val qualType = qual.tpe.widenIfUnstable
170170
def kind = if tree.isType then "type" else "value"
171171
val foundWithoutNull = qualType match
@@ -177,7 +177,7 @@ trait TypeAssigner {
177177
def addendum = err.selectErrorAddendum(tree, qual, qualType, importSuggestionAddendum, foundWithoutNull)
178178
val msg: Message =
179179
if tree.name == nme.CONSTRUCTOR then em"$qualType does not have a constructor"
180-
else NotAMember(qualType, tree.name, kind, addendum)
180+
else NotAMember(qualType, tree.name, kind, proto, addendum)
181181
errorType(msg, tree.srcPos)
182182

183183
def inaccessibleErrorType(tpe: NamedType, superAccess: Boolean, pos: SrcPos)(using Context): Type =
@@ -206,7 +206,7 @@ trait TypeAssigner {
206206
def assignType(tree: untpd.Select, qual: Tree)(using Context): Select =
207207
val rawType = selectionType(tree, qual)
208208
val checkedType = ensureAccessible(rawType, qual.isInstanceOf[Super], tree.srcPos)
209-
val ownType = checkedType.orElse(notAMemberErrorType(tree, qual))
209+
val ownType = checkedType.orElse(notAMemberErrorType(tree, qual, WildcardType))
210210
assignType(tree, ownType)
211211

212212
/** 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
@@ -665,7 +665,7 @@ class Typer(@constructorOnly nestingLevel: Int = 0) extends Namer
665665
then // we are in the arguments of a this(...) constructor call
666666
errorTree(tree, em"$tree is not accessible from constructor arguments")
667667
else
668-
errorTree(tree, MissingIdent(tree, kind, name))
668+
errorTree(tree, MissingIdent(tree, kind, name, pt))
669669
end typedIdent
670670

671671
/** (1) If this reference is neither applied nor selected, check that it does
@@ -754,7 +754,7 @@ class Typer(@constructorOnly nestingLevel: Int = 0) extends Namer
754754
case rawType: NamedType =>
755755
inaccessibleErrorType(rawType, superAccess, tree.srcPos)
756756
case _ =>
757-
notAMemberErrorType(tree, qual))
757+
notAMemberErrorType(tree, qual, pt))
758758
end typedSelect
759759

760760
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`

0 commit comments

Comments
 (0)