Skip to content

Commit c101b01

Browse files
feat: Add actionable diagnostic for missing members (#23572)
This closes #23469 LLMs were used to a moderate degree while working on this PR. --------- Co-authored-by: ghostbuster91 <ghostbuster91@users.noreply.github.com>
1 parent af061e2 commit c101b01

21 files changed

Lines changed: 560 additions & 139 deletions

File tree

compiler/src/dotty/tools/dotc/core/Decorators.scala

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ package core
55
import scala.annotation.tailrec
66
import scala.collection.mutable.ListBuffer
77

8-
import Contexts.*, Names.*, Phases.*, Symbols.*
8+
import Contexts.*, Names.*, Phases.*, Symbols.*, Types.*
99
import printing.{ Printer, Showable }, printing.Formatting.*, printing.Texts.*
1010
import transform.MegaPhase
1111
import reporting.{Message, NoExplanation}
@@ -80,6 +80,24 @@ object Decorators {
8080
NoSymbol
8181
}
8282

83+
extension (tp: Type)
84+
/** Replace synthetic parameter names (`x$0`, `x$1`, ...) of any method
85+
* type group in `tp` (recursing through curried `MethodType`s and
86+
* through `PolyType` result types) with dollar-free names (`x0`,
87+
* `x1`, ...). Lets a printed signature be reused as a valid Scala
88+
* identifier - e.g. in stub implementations or "method is not
89+
* defined" diagnostics.
90+
*/
91+
def withCleanParamNames(using Context): Type = tp match
92+
case mt: MethodType if mt.allParamNamesSynthetic =>
93+
val newNames = mt.paramNames.zipWithIndex.map((_, i) => termName("x" + i))
94+
mt.derivedLambdaType(newNames, mt.paramInfos, mt.resType.withCleanParamNames)
95+
case mt: MethodType =>
96+
mt.derivedLambdaType(mt.paramNames, mt.paramInfos, mt.resType.withCleanParamNames)
97+
case pt: PolyType =>
98+
pt.derivedLambdaType(pt.paramNames, pt.paramInfos, pt.resType.withCleanParamNames)
99+
case _ => tp
100+
83101
inline val MaxFilterRecursions = 10
84102

85103
/** Implements filterConserve, zipWithConserve methods

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ enum ErrorMessageID(val isActive: Boolean = true) extends java.lang.Enum[ErrorMe
246246
case AmbiguousTemplateNameID // errorNumber: 228
247247
case IndentationWarningID // errorNumber: 229
248248
case IllegalIdentifierID // errorNumber: 230
249+
case ConcreteClassHasUnimplementedMethodsID // errorNumer: 231
249250

250251
def errorNumber = ordinal - 1
251252

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3899,3 +3899,40 @@ final class IllegalIdentifier(name: Name)(using Context) extends SyntaxMsg(Illeg
38993899
|
39003900
|The prohibition against explicit `$$` may be ignored by enclosing the identifier in backquotes
39013901
|at the definition site."""
3902+
3903+
class ConcreteClassHasUnimplementedMethods(
3904+
clazz: ClassSymbol,
3905+
missingMethods: List[Symbol],
3906+
addendum: String,
3907+
methodActions: List[CodeAction])(using Context)
3908+
extends Message(ConcreteClassHasUnimplementedMethodsID), NoDisambiguation:
3909+
3910+
def kind = MessageKind.Declaration
3911+
3912+
private def showDecl(sym: Symbol)(using Context): String =
3913+
sym.asSeenFrom(clazz.thisType).mapInfo(_.withCleanParamNames).showDcl
3914+
3915+
private def prelude(using Context): String =
3916+
if clazz.isAnonymousClass || clazz.is(Module) then "object creation impossible"
3917+
else if clazz.is(Synthetic) then "instance cannot be created"
3918+
else s"$clazz needs to be abstract"
3919+
3920+
private def renderMissingMethods(using Context): List[String] =
3921+
val grouped = missingMethods.groupBy(_.owner).toList
3922+
grouped.sortBy(_._1.name).map { case (owner, members) =>
3923+
val sigs = members.sortBy(_.name).map(s => s"- ${showDecl(s)}")
3924+
s"""Members declared in ${owner.fullName}:
3925+
|${sigs.mkString("\n")}""".stripMargin
3926+
}
3927+
3928+
def msg(using Context) = missingMethods match
3929+
case single :: Nil =>
3930+
val notDefined = s"${showDecl(single)} in ${single.owner.showLocated} is not defined"
3931+
s"$prelude, since $notDefined$addendum"
3932+
case _ =>
3933+
s"""$prelude, since it has ${missingMethods.size} unimplemented members.
3934+
|
3935+
|${renderMissingMethods.mkString("\n\n")}""".stripMargin
3936+
3937+
def explain(using Context) = ""
3938+
override def actions(using Context) = methodActions

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

Lines changed: 155 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import Annotations.Annotation
2424
import Constants.Constant
2525
import cc.{stripCapturing, CCState}
2626
import cc.Mutability.isUpdateMethod
27+
import dotty.tools.dotc.util.Chars.{isLineBreakChar, isWhitespace}
2728

2829
object RefChecks {
2930
import tpd.*
@@ -338,6 +339,29 @@ object RefChecks {
338339
val clazzNameEnd = clazz.srcPos.span.start + clazz.name.stripModuleClassSuffix.lastPart.length
339340
clazz.srcPos.sourcePos.copy(span = clazz.srcPos.span.withEnd(clazzNameEnd))
340341

342+
/** True when `clazz` is the synthetic anonymous class generated for an
343+
* enum case (either a simple case via `$new` or a parameterized one
344+
* marked with `EnumCase`). */
345+
def isEnumAnonCls = // courtesy of Checking.checkEnum
346+
clazz.isAnonymousClass
347+
&& clazz.owner.isTerm
348+
&& {
349+
clazz.owner.isAllOf(EnumCase)
350+
|| (clazz.owner.name eq nme.DOLLAR_NEW) && clazz.owner.isAllOf(Private | Synthetic)
351+
}
352+
353+
/** Positions at which to report a "needs to be abstract" / "object creation
354+
* impossible" error for `clazz`. Normally a single position pointing at
355+
* the class name; for enum-case anonymous classes the error is moved to
356+
* the case definition(s) so the user sees it on `case Foo` rather than on
357+
* the synthetic `$anon`. */
358+
def classErrorPositions: List[util.SrcPos] =
359+
if !isEnumAnonCls then clazzNamePos :: Nil
360+
else if clazz.owner.isAllOf(EnumCase) then clazz.owner.srcPos :: Nil
361+
else
362+
val e = clazz.parentSyms.head
363+
e.children.filter(_.info.typeSymbol == e).map(_.srcPos)
364+
341365
def printMixinOverrideErrors(): Unit =
342366
mixinOverrideErrors.toList match {
343367
case Nil =>
@@ -646,15 +670,15 @@ object RefChecks {
646670
// Verifying a concrete class has nothing unimplemented.
647671
if (!clazz.isOneOf(AbstractOrTrait)) {
648672
val abstractErrors = new mutable.ListBuffer[String]
673+
val concreteClassUnimplementedMethodError = new mutable.ListBuffer[Message]
649674
def abstractErrorMessage =
650675
// a little formatting polish
651676
if (abstractErrors.size <= 2) abstractErrors.mkString(" ")
652677
else abstractErrors.tail.mkString(abstractErrors.head + ":\n", "\n", "")
653678

654-
def abstractClassError(mustBeMixin: Boolean, msg: String): Unit = {
679+
def abstractClassError(msg: String): Unit = {
655680
def prelude = (
656681
if (clazz.isAnonymousClass || clazz.is(Module)) "object creation impossible"
657-
else if (mustBeMixin) s"$clazz needs to be a mixin"
658682
else if clazz.is(Synthetic) then "instance cannot be created"
659683
else s"$clazz needs to be abstract"
660684
) + ", since"
@@ -733,56 +757,28 @@ object RefChecks {
733757
.distinctBy(_.signature) // Avoid duplication for similar definitions (#19731)
734758
}
735759

736-
/** Replace synthetic parameter names (x$0, x$1, ...) with
737-
* dollar-free versions (x0, x1, ...) so that stub implementations
738-
* are directly usable as valid Scala identifiers.
739-
*/
740-
def replaceSyntheticParamNames(tp: Type): Type = tp match
741-
case mt: MethodType if mt.allParamNamesSynthetic =>
742-
val newNames = mt.paramNames.zipWithIndex.map((_, i) => termName("x" + i))
743-
mt.derivedLambdaType(newNames, mt.paramInfos, replaceSyntheticParamNames(mt.resType))
744-
case mt: MethodType =>
745-
mt.derivedLambdaType(mt.paramNames, mt.paramInfos, replaceSyntheticParamNames(mt.resType))
746-
case pt: PolyType =>
747-
pt.derivedLambdaType(pt.paramNames, pt.paramInfos, replaceSyntheticParamNames(pt.resType))
748-
case _ => tp
749-
750-
def stubImplementations: List[String] = {
751-
// Grouping missing methods by the declaring class
752-
val regrouped = missingMethods.groupBy(_.owner).toList
753-
def membersStrings(members: List[Symbol]) =
754-
members.sortBy(_.name.toString).map: sym =>
755-
val denot = sym.asSeenFrom(clazz.thisType)
756-
denot.mapInfo(replaceSyntheticParamNames).showDcl + " = ???"
757-
758-
if (regrouped.tail.isEmpty)
759-
membersStrings(regrouped.head._2)
760-
else (regrouped.sortBy(_._1.name.toString()) flatMap {
761-
case (owner, members) =>
762-
("// Members declared in " + owner.fullName) +: membersStrings(members) :+ ""
763-
}).init
764-
}
760+
def stubImplementations: List[String] =
761+
missingMethods.sortBy(_.name.toString).map: sym =>
762+
sym.asSeenFrom(clazz.thisType).mapInfo(_.withCleanParamNames).showDcl + " = ???"
765763

766-
// If there are numerous missing methods, we presume they are aware of it and
767-
// give them a nicely formatted set of method signatures for implementing.
768-
if (missingMethods.size > 1) {
769-
abstractClassError(false, "it has " + missingMethods.size + " unimplemented members.")
770-
val preface =
771-
"""|/** As seen from %s, the missing signatures are as follows.
772-
| * For convenience, these are usable as stub implementations.
773-
| */
774-
|""".stripMargin.format(clazz)
775-
abstractErrors += stubImplementations.map(" " + _ + "\n").mkString(preface, "", "")
764+
if missingMethods.isEmpty then return
765+
766+
lazy val actions =
767+
createAddMissingMethodsAction(clazz, stubImplementations)
768+
++ createMakeClassAbstractAction(clazz)
769+
770+
if missingMethods.size > 1 then
771+
concreteClassUnimplementedMethodError += ConcreteClassHasUnimplementedMethods(
772+
clazz, missingMethods, addendum = "", actions)
776773
return
777-
}
778774

779775
for (member <- missingMethods) {
780776
def showDclAndLocation(sym: Symbol) =
781-
s"${sym.mapInfo(replaceSyntheticParamNames).showDcl} in ${sym.owner.showLocated}"
777+
s"${sym.mapInfo(_.withCleanParamNames).showDcl} in ${sym.owner.showLocated}"
782778
def undefined(msg: String) =
783-
val notdefined = s"${showDclAndLocation(member)} is not defined"
784-
val text = if !msg.isEmpty then s"$notdefined $msg" else notdefined
785-
abstractClassError(mustBeMixin = false, text)
779+
val addendum = if msg.isEmpty then "" else s" $msg"
780+
concreteClassUnimplementedMethodError += ConcreteClassHasUnimplementedMethods(
781+
clazz, missingMethods, addendum, actions)
786782
val underlying = member.underlyingSymbol
787783

788784
// Give a specific error message for abstract vars based on why it fails:
@@ -875,7 +871,7 @@ object RefChecks {
875871
val impl1 = clazz.thisType.nonPrivateMember(decl.name) // DEBUG
876872
report.log(i"${impl1}: ${impl1.info}") // DEBUG
877873
report.log(i"${clazz.thisType.memberInfo(decl)}") // DEBUG
878-
abstractClassError(false, "there is a deferred declaration of " + infoString(decl) +
874+
abstractClassError("there is a deferred declaration of " + infoString(decl) +
879875
" which is not implemented in a subclass" + err.abstractVarMessage(decl))
880876
}
881877
if (bc.asClass.superClass.is(Abstract))
@@ -945,25 +941,17 @@ object RefChecks {
945941
|This is a limitation that enables better GADT constraints in case class patterns""".stripMargin
946942
do report.errorOrMigrationWarning(withExplain, clazz.srcPos, MigrationVersion.Scala2to3)
947943
checkNoAbstractMembers()
948-
if (abstractErrors.isEmpty)
944+
if (abstractErrors.isEmpty && concreteClassUnimplementedMethodError.isEmpty)
949945
checkNoAbstractDecls(clazz)
950946

951947
if abstractErrors.nonEmpty then
952-
val isEnumAnonCls = // courtesy of Checking.checkEnum
953-
clazz.isAnonymousClass
954-
&& clazz.owner.isTerm
955-
&& {
956-
clazz.owner.isAllOf(EnumCase)
957-
|| (clazz.owner.name eq nme.DOLLAR_NEW) && clazz.owner.isAllOf(Private | Synthetic)
958-
}
959-
if !isEnumAnonCls then
960-
report.error(abstractErrorMessage, clazzNamePos)
961-
else if clazz.owner.isAllOf(EnumCase) then
962-
report.error(abstractErrorMessage, clazz.owner.srcPos)
963-
else
964-
val e = clazz.parentSyms.head
965-
for child <- e.children if child.info.typeSymbol == e do // report all simple cases
966-
report.error(abstractErrorMessage, child.srcPos)
948+
val msg = abstractErrorMessage
949+
classErrorPositions.foreach(report.error(msg, _))
950+
for
951+
message <- concreteClassUnimplementedMethodError
952+
pos <- classErrorPositions
953+
do
954+
report.error(message, pos)
967955

968956
checkMemberTypesOK()
969957
checkCaseClassInheritanceInvariant()
@@ -1349,7 +1337,111 @@ object RefChecks {
13491337
tree.tpe match
13501338
case tp: NamedType if tp.prefix.typeSymbol != ctx.enclosingClass =>
13511339
report.warning(UnqualifiedCallToAnyRefMethod(tree, tree.symbol), tree)
1340+
case _ => ()
1341+
1342+
private def createAddMissingMethodsAction(clazz: ClassSymbol, methods: List[String])(using Context): List[CodeAction] =
1343+
// Synthetic classes (e.g. anonymous classes generated by macros) may have
1344+
// no corresponding node in the untyped AST. In that case there's nothing
1345+
// to anchor a source-level patch to, so we don't offer the action.
1346+
NavigateAST.untypedPath(clazz.span) match
1347+
case (untypedTree: untpd.Tree) :: _ =>
1348+
addMissingMethodsActionPatch(clazz, methods, untypedTree)
1349+
case _ => Nil
1350+
1351+
/** Code action that prepends the `abstract` modifier to the class declaration.
1352+
*
1353+
* Only offered for regular classes that can legally become abstract. We skip:
1354+
* - modules (`object`s are implicitly final),
1355+
* - anonymous classes (no source-level keyword to modify),
1356+
* - case classes (cannot be abstract),
1357+
* - already-final classes (mutually exclusive with abstract),
1358+
* - synthetic classes (enum case singletons, given bodies, etc.).
1359+
*
1360+
* As with [[createAddMissingMethodsAction]], if the class has no node in
1361+
* the untyped AST (e.g. macro-generated) we skip silently.
1362+
*/
1363+
private def createMakeClassAbstractAction(clazz: ClassSymbol)(using Context): List[CodeAction] =
1364+
import dotty.tools.dotc.rewrites.Rewrites.ActionPatch
1365+
1366+
val ineligible =
1367+
clazz.is(Module) || clazz.isAnonymousClass || clazz.is(Case) ||
1368+
clazz.is(Final) || clazz.is(Synthetic)
1369+
if ineligible then Nil
1370+
else NavigateAST.untypedPath(clazz.span) match
1371+
case (untypedTree: untpd.Tree) :: _ =>
1372+
val insertPos = untypedTree.sourcePos.withSpan(Span(untypedTree.span.start))
1373+
val patch = ActionPatch(insertPos, "abstract ")
1374+
List(CodeAction(s"Make `${clazz.name.show}` abstract", None, List(patch)))
1375+
case _ => Nil
1376+
1377+
private def addMissingMethodsActionPatch(
1378+
clazz: ClassSymbol,
1379+
methods: List[String],
1380+
untypedTree: untpd.Tree)(using Context): List[CodeAction] = {
1381+
import dotty.tools.dotc.rewrites.Rewrites.ActionPatch
1382+
1383+
val classSrcPos = clazz.srcPos
1384+
val content = classSrcPos.sourcePos.source.content()
1385+
val span = classSrcPos.endPos.span
1386+
1387+
val classText = new String(content.slice(untypedTree.span.start, untypedTree.span.end))
1388+
val classHasBraces = classText.contains("{") && classText.contains("}")
1389+
1390+
// Indentation for inserted methods
1391+
val lineStart = content.lastIndexWhere(isLineBreakChar, end = span.end - 1) + 1
1392+
val baseIndent = new String(content.slice(lineStart, span.end).takeWhile(c => c == ' ' || c == '\t'))
1393+
val indent = baseIndent + " "
1394+
1395+
val formattedMethods = methods.map(m => s"$indent$m").mkString(System.lineSeparator())
1396+
1397+
val isBracelessSyntax = untypedTree match
1398+
case untpd.TypeDef(_, tmpl: untpd.Template) =>
1399+
!classText.contains("{") && tmpl.body.nonEmpty
1400+
case _ => false
1401+
1402+
if (classHasBraces) {
1403+
val insertBeforeBrace = untypedTree.sourcePos.withSpan(Span(untypedTree.span.end - 1))
1404+
val braceStart = classText.indexOf('{')
1405+
val braceEnd = classText.lastIndexOf('}')
1406+
val bodyBetweenBraces = classText.slice(braceStart + 1, braceEnd)
1407+
val bodyIsEmpty = bodyBetweenBraces.forall(_.isWhitespace)
1408+
val bodyContainsNewLine = bodyBetweenBraces.exists(isLineBreakChar)
1409+
1410+
val prefix = if (bodyContainsNewLine) "" else System.lineSeparator()
1411+
val patchText =
1412+
prefix +
1413+
formattedMethods +
1414+
System.lineSeparator()
1415+
1416+
val patch = ActionPatch(insertBeforeBrace, patchText)
1417+
List(CodeAction("Add missing methods", None, List(patch)))
1418+
} else if (isBracelessSyntax) {
1419+
val insertAfterLastDef = untypedTree match
1420+
case untpd.TypeDef(_, tmpl: untpd.Template) if tmpl.body.nonEmpty =>
1421+
val lastDef = tmpl.body.last
1422+
lastDef.sourcePos.withSpan(Span(lastDef.span.end))
13521423
case _ =>
1424+
untypedTree.sourcePos.withSpan(Span(untypedTree.span.end))
1425+
1426+
val patchText = System.lineSeparator() + formattedMethods
1427+
1428+
val patch = ActionPatch(insertAfterLastDef, patchText)
1429+
List(CodeAction("Add missing methods", None, List(patch)))
1430+
} else {
1431+
// Class has no body – add whole `{ ... }` after class header, same line
1432+
val insertAfterHeader = untypedTree.sourcePos.withSpan(Span(untypedTree.span.end))
1433+
1434+
val patchText =
1435+
" {" + System.lineSeparator() +
1436+
formattedMethods + System.lineSeparator() +
1437+
"}"
1438+
1439+
val patch = ActionPatch(insertAfterHeader, patchText)
1440+
List(CodeAction("Add missing methods", None, List(patch)))
1441+
}
1442+
}
1443+
1444+
13531445
}
13541446
import RefChecks.*
13551447

0 commit comments

Comments
 (0)