Skip to content

Commit a1f0bb0

Browse files
committed
feat: Add actionable diagnostic for missing members
1 parent 42fdd76 commit a1f0bb0

File tree

4 files changed

+222
-22
lines changed

4 files changed

+222
-22
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ enum ErrorMessageID(val isActive: Boolean = true) extends java.lang.Enum[ErrorMe
232232
case UnnecessaryNN // errorNumber: 216
233233
case ErasedNotPureID // errorNumber: 217
234234
case IllegalErasedDefID // errorNumber: 218
235+
case ConcreteClassHasUnimplementedMethodsID // errorNumer: 219
235236

236237
def errorNumber = ordinal - 1
237238

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3652,3 +3652,30 @@ final class IllegalErasedDef(sym: Symbol)(using Context) extends TypeMsg(Illegal
36523652
override protected def explain(using Context): String =
36533653
"Only non-lazy immutable values can be `erased`"
36543654
end IllegalErasedDef
3655+
3656+
class ConcreteClassHasUnimplementedMethods(clazz: ClassSymbol, missingMethods: List[dotty.tools.dotc.core.Symbols.Symbol], val actions: List[CodeAction])(using Context)
3657+
extends Message(ConcreteClassHasUnimplementedMethodsID):
3658+
3659+
def kind = MessageKind.Declaration
3660+
3661+
def renderMissingMethods: List[String] = {
3662+
// Grouping missing methods by the declaring class
3663+
val regrouped = missingMethods.groupBy(_.owner).toList
3664+
def membersStrings(members: List[Symbol]) =
3665+
members.sortBy(_.name.toString).map(_.asSeenFrom(clazz.thisType).showDcl).map(m => s"- $m")
3666+
3667+
(regrouped.sortBy(_._1.name.toString()) map {
3668+
case (owner, members) =>
3669+
s"""Members declared in ${owner.fullName}:
3670+
|${membersStrings(members).mkString("\n")}"""
3671+
})
3672+
}
3673+
3674+
def msg(using Context) =
3675+
s"""$clazz needs to be abstract, since it has ${missingMethods.size} unimplemented members.
3676+
|
3677+
|${renderMissingMethods.mkString("\n")}
3678+
|""".stripMargin
3679+
3680+
def explain(using Context) = ""
3681+
override def actions(using Context) = this.actions

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

Lines changed: 82 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -629,15 +629,15 @@ object RefChecks {
629629
// Verifying a concrete class has nothing unimplemented.
630630
if (!clazz.isOneOf(AbstractOrTrait)) {
631631
val abstractErrors = new mutable.ListBuffer[String]
632+
val concreteClassUnimplementedMethodError = new mutable.ListBuffer[Message]
632633
def abstractErrorMessage =
633634
// a little formatting polish
634635
if (abstractErrors.size <= 2) abstractErrors.mkString(" ")
635636
else abstractErrors.tail.mkString(abstractErrors.head + ":\n", "\n", "")
636637

637-
def abstractClassError(mustBeMixin: Boolean, msg: String): Unit = {
638+
def abstractClassError(msg: String): Unit = {
638639
def prelude = (
639640
if (clazz.isAnonymousClass || clazz.is(Module)) "object creation impossible"
640-
else if (mustBeMixin) s"$clazz needs to be a mixin"
641641
else if clazz.is(Synthetic) then "instance cannot be created"
642642
else s"$clazz needs to be abstract"
643643
) + ", since"
@@ -717,37 +717,22 @@ object RefChecks {
717717
}
718718

719719
def stubImplementations: List[String] = {
720-
// Grouping missing methods by the declaring class
721-
val regrouped = missingMethods.groupBy(_.owner).toList
722720
def membersStrings(members: List[Symbol]) =
723721
members.sortBy(_.name.toString).map(_.asSeenFrom(clazz.thisType).showDcl + " = ???")
724722

725-
if (regrouped.tail.isEmpty)
726-
membersStrings(regrouped.head._2)
727-
else (regrouped.sortBy(_._1.name.toString()) flatMap {
728-
case (owner, members) =>
729-
("// Members declared in " + owner.fullName) +: membersStrings(members) :+ ""
730-
}).init
723+
membersStrings(missingMethods)
731724
}
732725

733-
// If there are numerous missing methods, we presume they are aware of it and
734-
// give them a nicely formatted set of method signatures for implementing.
735-
if (missingMethods.size > 1) {
736-
abstractClassError(false, "it has " + missingMethods.size + " unimplemented members.")
737-
val preface =
738-
"""|/** As seen from %s, the missing signatures are as follows.
739-
| * For convenience, these are usable as stub implementations.
740-
| */
741-
|""".stripMargin.format(clazz)
742-
abstractErrors += stubImplementations.map(" " + _ + "\n").mkString(preface, "", "")
726+
if (missingMethods.size >= 1) {
727+
concreteClassUnimplementedMethodError += ConcreteClassHasUnimplementedMethods(clazz, missingMethods, createAddMissingMethodsAction(clazz, stubImplementations))
743728
return
744729
}
745730

746731
for (member <- missingMethods) {
747732
def showDclAndLocation(sym: Symbol) =
748733
s"${sym.showDcl} in ${sym.owner.showLocated}"
749734
def undefined(msg: String) =
750-
abstractClassError(false, s"${showDclAndLocation(member)} is not defined $msg")
735+
abstractClassError(s"${showDclAndLocation(member)} is not defined $msg")
751736
val underlying = member.underlyingSymbol
752737

753738
// Give a specific error message for abstract vars based on why it fails:
@@ -840,7 +825,7 @@ object RefChecks {
840825
val impl1 = clazz.thisType.nonPrivateMember(decl.name) // DEBUG
841826
report.log(i"${impl1}: ${impl1.info}") // DEBUG
842827
report.log(i"${clazz.thisType.memberInfo(decl)}") // DEBUG
843-
abstractClassError(false, "there is a deferred declaration of " + infoString(decl) +
828+
abstractClassError("there is a deferred declaration of " + infoString(decl) +
844829
" which is not implemented in a subclass" + err.abstractVarMessage(decl))
845830
}
846831
if (bc.asClass.superClass.is(Abstract))
@@ -916,6 +901,8 @@ object RefChecks {
916901
if (abstractErrors.nonEmpty)
917902
report.error(abstractErrorMessage, clazz.srcPos)
918903

904+
concreteClassUnimplementedMethodError.foreach(report.error(_, clazz.srcPos))
905+
919906
checkMemberTypesOK()
920907
checkCaseClassInheritanceInvariant()
921908
}
@@ -1272,6 +1259,79 @@ object RefChecks {
12721259
case tp: NamedType if tp.prefix.typeSymbol != ctx.owner.enclosingClass =>
12731260
report.warning(UnqualifiedCallToAnyRefMethod(tree, tree.symbol), tree)
12741261
case _ => ()
1262+
1263+
private def createAddMissingMethodsAction(clazz: ClassSymbol, methods: List[String])(using Context): List[CodeAction] = {
1264+
import dotty.tools.dotc.rewrites.Rewrites.ActionPatch
1265+
import dotty.tools.dotc.util.Spans
1266+
import dotty.tools.dotc.ast.untpd
1267+
1268+
val untypedTree = NavigateAST
1269+
.untypedPath(clazz.span)
1270+
.head
1271+
.asInstanceOf[untpd.Tree]
1272+
1273+
val classSrcPos = clazz.srcPos
1274+
val content = classSrcPos.sourcePos.source.content()
1275+
val span = classSrcPos.endPos.span
1276+
1277+
val classText = new String(content.slice(untypedTree.span.start, untypedTree.span.end))
1278+
val classHasBraces = classText.contains("{") && classText.contains("}")
1279+
1280+
// Indentation for inserted methods
1281+
val lineStart = content.lastIndexWhere(c => c == '\n', end = span.end - 1) + 1
1282+
val baseIndent = new String(content.slice(lineStart, span.end).takeWhile(c => c == ' ' || c == '\t'))
1283+
val indent = baseIndent + " "
1284+
1285+
val formattedMethods = methods.map(m => s"$indent$m").mkString(System.lineSeparator())
1286+
1287+
val isBracelessSyntax = untypedTree match
1288+
case untpd.TypeDef(_, tmpl: untpd.Template) =>
1289+
!classText.contains("{") && tmpl.body.nonEmpty
1290+
case _ => false
1291+
1292+
if (classHasBraces) {
1293+
val insertBeforeBrace = untypedTree.sourcePos.withSpan(Span(untypedTree.span.end - 1))
1294+
val braceStart = classText.indexOf('{')
1295+
val braceEnd = classText.lastIndexOf('}')
1296+
val bodyBetweenBraces = classText.slice(braceStart + 1, braceEnd)
1297+
val bodyIsEmpty = bodyBetweenBraces.forall(_.isWhitespace)
1298+
val bodyContainsNewLine = bodyBetweenBraces.contains(System.lineSeparator())
1299+
1300+
val prefix = if (bodyContainsNewLine) "" else System.lineSeparator()
1301+
val patchText =
1302+
prefix +
1303+
formattedMethods +
1304+
System.lineSeparator()
1305+
1306+
val patch = ActionPatch(insertBeforeBrace, patchText)
1307+
List(CodeAction("Add missing methods", None, List(patch)))
1308+
} else if (isBracelessSyntax) {
1309+
val insertAfterLastDef = untypedTree match
1310+
case untpd.TypeDef(_, tmpl: untpd.Template) if tmpl.body.nonEmpty =>
1311+
val lastDef = tmpl.body.last
1312+
lastDef.sourcePos.withSpan(Span(lastDef.span.end))
1313+
case _ =>
1314+
untypedTree.sourcePos.withSpan(Span(untypedTree.span.end))
1315+
1316+
val patchText = System.lineSeparator() + formattedMethods
1317+
1318+
val patch = ActionPatch(insertAfterLastDef, patchText)
1319+
List(CodeAction("Add missing methods", None, List(patch)))
1320+
} else {
1321+
// Class has no body – add whole `{ ... }` after class header, same line
1322+
val insertAfterHeader = untypedTree.sourcePos.withSpan(Span(untypedTree.span.end))
1323+
1324+
val patchText =
1325+
" {" + System.lineSeparator() +
1326+
formattedMethods + System.lineSeparator() +
1327+
"}"
1328+
1329+
val patch = ActionPatch(insertAfterHeader, patchText)
1330+
List(CodeAction("Add missing methods", None, List(patch)))
1331+
}
1332+
}
1333+
1334+
12751335
}
12761336
import RefChecks.*
12771337

compiler/test/dotty/tools/dotc/reporting/CodeActionTest.scala

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,118 @@ class CodeActionTest extends DottyTest:
179179
ctxx = ctxx
180180
)
181181

182+
@Test def insertMissingSingleMethodToNonEmptyClass =
183+
checkCodeAction(
184+
code =
185+
"""trait Animal {
186+
| def name: String
187+
|}
188+
|class Dog extends Animal {
189+
| def bbb = 2
190+
|}
191+
|""".stripMargin,
192+
title = "Add missing methods",
193+
expected =
194+
"""trait Animal {
195+
| def name: String
196+
|}
197+
|class Dog extends Animal {
198+
| def bbb = 2
199+
| def name: String = ???
200+
|}
201+
|""".stripMargin,
202+
afterPhase= "erasure"
203+
)
204+
205+
@Test def insertMissingMethodsFromMultipleSources =
206+
checkCodeAction(
207+
code =
208+
"""trait Animal {
209+
| def name: String
210+
|}
211+
|trait Car {
212+
| def wheels: Int
213+
|}
214+
|class Dog extends Animal with Car {
215+
|}
216+
|""".stripMargin,
217+
title = "Add missing methods",
218+
expected =
219+
"""trait Animal {
220+
| def name: String
221+
|}
222+
|trait Car {
223+
| def wheels: Int
224+
|}
225+
|class Dog extends Animal with Car {
226+
| def name: String = ???
227+
| def wheels: Int = ???
228+
|}
229+
|""".stripMargin,
230+
afterPhase= "erasure"
231+
)
232+
233+
@Test def insertMissingMethodIntoEmptyClassWithBrackets =
234+
checkCodeAction(
235+
code =
236+
"""trait Animal {
237+
| def name: String
238+
|}
239+
|class Dog extends Animal {}
240+
|""".stripMargin,
241+
title = "Add missing methods",
242+
expected =
243+
"""trait Animal {
244+
| def name: String
245+
|}
246+
|class Dog extends Animal {
247+
| def name: String = ???
248+
|}
249+
|""".stripMargin,
250+
afterPhase= "erasure"
251+
)
252+
253+
@Test def insertMissingMethodIntoEmptyClassWithoutBrackets =
254+
checkCodeAction(
255+
code =
256+
"""trait Animal {
257+
| def name: String
258+
|}
259+
|class Dog extends Animal
260+
|""".stripMargin,
261+
title = "Add missing methods",
262+
expected =
263+
"""trait Animal {
264+
| def name: String
265+
|}
266+
|class Dog extends Animal {
267+
| def name: String = ???
268+
|}
269+
|""".stripMargin,
270+
afterPhase= "erasure"
271+
)
272+
273+
@Test def insertMissingMethodIntoNonEmptyClassWithBraclessSyntax =
274+
checkCodeAction(
275+
code =
276+
"""trait Animal {
277+
| def name: String
278+
|}
279+
|class Dog extends Animal:
280+
| def bbb = 2
281+
|""".stripMargin,
282+
title = "Add missing methods",
283+
expected =
284+
"""trait Animal {
285+
| def name: String
286+
|}
287+
|class Dog extends Animal:
288+
| def bbb = 2
289+
| def name: String = ???
290+
|""".stripMargin,
291+
afterPhase= "erasure"
292+
)
293+
182294
// Make sure we're not using the default reporter, which is the ConsoleReporter,
183295
// meaning they will get reported in the test run and that's it.
184296
private def newContext =

0 commit comments

Comments
 (0)