Skip to content

Commit cbd18f3

Browse files
committed
Add -quickfix compiler option to apply quick fixes to source files
With 2.13.12 the compiler starts providing quick fixes with certain warnings and errors. Typically these are presented in IDEs, however it can also be practical to have the compiler directly patch the source files. From `-quickfix:help`: ``` Apply quick fixes provided by the compiler for warnings and errors to source files. Syntax: -quickfix:<filter>,...,<filter> <filter> syntax is the same as for configurable warnings, see `-Wconf:help`. Examples: -quickfix:any apply all available quick fixes -quickfix:msg=Auto-application apply quick fixes where the message contains "Auto-application" Use `-Wconf:any:warning-verbose` to display applicable message filters with each warning. ```
1 parent 98a4234 commit cbd18f3

27 files changed

+323
-72
lines changed

src/compiler/scala/tools/nsc/Reporting.scala

Lines changed: 159 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,15 @@ package scala
1414
package tools
1515
package nsc
1616

17+
import java.io.IOException
18+
import java.nio.charset.Charset
19+
import java.nio.file.{Files, Path, Paths}
1720
import java.util.regex.PatternSyntaxException
18-
import scala.annotation.nowarn
21+
import scala.annotation.{nowarn, tailrec}
1922
import scala.collection.mutable
2023
import scala.reflect.internal
2124
import scala.reflect.internal.util.StringOps.countElementsAsString
22-
import scala.reflect.internal.util.{CodeAction, NoSourceFile, Position, SourceFile}
25+
import scala.reflect.internal.util.{CodeAction, NoSourceFile, Position, SourceFile, TextEdit}
2326
import scala.tools.nsc.Reporting.Version.{NonParseableVersion, ParseableVersion}
2427
import scala.tools.nsc.Reporting._
2528
import scala.tools.nsc.settings.NoScalaVersion
@@ -62,13 +65,50 @@ trait Reporting extends internal.Reporting { self: ast.Positions with Compilatio
6265
else conf
6366
}
6467

68+
private lazy val quickfixFilters = {
69+
if (settings.quickfix.isSetByUser && settings.quickfix.value.isEmpty) {
70+
globalError(s"Missing message filter for `-quickfix`; see `-quickfix:help` or use `-quickfix:any` to apply all available quick fixes.")
71+
Nil
72+
} else {
73+
val parsed = settings.quickfix.value.map(WConf.parseFilter(_, rootDirPrefix))
74+
val msgs = parsed.collect { case Left(msg) => msg }
75+
if (msgs.nonEmpty) {
76+
globalError(s"Failed to parse `-quickfix` filters: ${settings.quickfix.value.mkString(",")}\n${msgs.mkString("\n")}")
77+
Nil
78+
} else parsed.collect { case Right(f) => f }
79+
}
80+
}
81+
82+
private val skipRewriteAction = Set(Action.WarningSummary, Action.InfoSummary, Action.Silent)
83+
84+
private def registerTextEdit(m: Message): Boolean =
85+
if (quickfixFilters.exists(f => f.matches(m))) {
86+
textEdits.addAll(m.actions.flatMap(_.edits))
87+
true
88+
}
89+
else false
90+
91+
private def registerErrorTextEdit(pos: Position, msg: String, actions: List[CodeAction]): Boolean = {
92+
val matches = quickfixFilters.exists({
93+
case MessageFilter.Any => true
94+
case mp: MessageFilter.MessagePattern => mp.check(msg)
95+
case sp: MessageFilter.SourcePattern => sp.check(pos)
96+
case _ => false
97+
})
98+
if (matches)
99+
textEdits.addAll(actions.flatMap(_.edits))
100+
matches
101+
}
102+
65103
private val summarizedWarnings: mutable.Map[WarningCategory, mutable.LinkedHashMap[Position, Message]] = mutable.HashMap.empty
66104
private val summarizedInfos: mutable.Map[WarningCategory, mutable.LinkedHashMap[Position, Message]] = mutable.HashMap.empty
67105

68106
private val suppressions: mutable.LinkedHashMap[SourceFile, mutable.ListBuffer[Suppression]] = mutable.LinkedHashMap.empty
69107
private val suppressionsComplete: mutable.Set[SourceFile] = mutable.Set.empty
70108
private val suspendedMessages: mutable.LinkedHashMap[SourceFile, mutable.LinkedHashSet[Message]] = mutable.LinkedHashMap.empty
71109

110+
private val textEdits: mutable.Set[TextEdit] = mutable.Set.empty
111+
72112
// Used in REPL. The old run is used for parsing. Don't discard its suspended warnings.
73113
def initFrom(old: PerRunReporting): Unit = {
74114
suspendedMessages ++= old.suspendedMessages
@@ -100,6 +140,10 @@ trait Reporting extends internal.Reporting { self: ast.Positions with Compilatio
100140
sups <- suppressions.remove(source)
101141
sup <- sups.reverse
102142
} if (!sup.used && !sup.synthetic) issueWarning(Message.Plain(sup.annotPos, "@nowarn annotation does not suppress any warnings", WarningCategory.UnusedNowarn, "", Nil))
143+
144+
// apply quick fixes
145+
quickfix(textEdits)
146+
textEdits.clear()
103147
}
104148

105149
def reportSuspendedMessages(unit: CompilationUnit): Unit = {
@@ -119,6 +163,14 @@ trait Reporting extends internal.Reporting { self: ast.Positions with Compilatio
119163
}
120164

121165
private def issueWarning(warning: Message): Unit = {
166+
val action = wconf.action(warning)
167+
168+
val quickfixed = {
169+
if (!skipRewriteAction(action) && registerTextEdit(warning)) s"[rewritten by -quickfix] ${warning.msg}"
170+
else if (warning.actions.exists(_.edits.nonEmpty)) s"[quick fix available] ${warning.msg}"
171+
else warning.msg
172+
}
173+
122174
def ifNonEmpty(kind: String, filter: String) = if (filter.nonEmpty) s", $kind=$filter" else ""
123175
def filterHelp =
124176
s"msg=<part of the message>, cat=${warning.category.name}" +
@@ -133,12 +185,13 @@ trait Reporting extends internal.Reporting { self: ast.Positions with Compilatio
133185
"\nScala 3 migration messages are errors under -Xsource:3. Use -Wconf / @nowarn to filter them or add -Xmigration to demote them to warnings."
134186
else ""
135187
def helpMsg(kind: String, isError: Boolean = false) =
136-
s"${warning.msg}${scala3migration(isError)}\nApplicable -Wconf / @nowarn filters for this $kind: $filterHelp"
137-
wconf.action(warning) match {
188+
s"$quickfixed${scala3migration(isError)}\nApplicable -Wconf / @nowarn filters for this $kind: $filterHelp"
189+
190+
action match {
138191
case Action.Error => reporter.error(warning.pos, helpMsg("fatal warning", isError = true), warning.actions)
139-
case Action.Warning => reporter.warning(warning.pos, warning.msg, warning.actions)
192+
case Action.Warning => reporter.warning(warning.pos, quickfixed, warning.actions)
140193
case Action.WarningVerbose => reporter.warning(warning.pos, helpMsg("warning"), warning.actions)
141-
case Action.Info => reporter.echo(warning.pos, warning.msg, warning.actions)
194+
case Action.Info => reporter.echo(warning.pos, quickfixed, warning.actions)
142195
case Action.InfoVerbose => reporter.echo(warning.pos, helpMsg("message"), warning.actions)
143196
case a @ (Action.WarningSummary | Action.InfoSummary) =>
144197
val m = summaryMap(a, warning.category.summaryCategory)
@@ -299,6 +352,16 @@ trait Reporting extends internal.Reporting { self: ast.Positions with Compilatio
299352
def warning(pos: Position, msg: String, category: WarningCategory, site: Symbol, origin: String): Unit =
300353
issueIfNotSuppressed(Message.Origin(pos, msg, category, siteName(site), origin, actions = Nil))
301354

355+
// Remember CodeActions that match `-quickfix` and report the error through the reporter
356+
def error(pos: Position, msg: String, actions: List[CodeAction]): Unit = {
357+
val quickfixed = {
358+
if (registerErrorTextEdit(pos, msg, actions)) s"[rewritten by -quickfix] $msg"
359+
else if (actions.exists(_.edits.nonEmpty)) s"[quick fix available] $msg"
360+
else msg
361+
}
362+
reporter.error(pos, quickfixed, actions)
363+
}
364+
302365
// used by Global.deprecationWarnings, which is used by sbt
303366
def deprecationWarnings: List[(Position, String)] = summaryMap(Action.WarningSummary, WarningCategory.Deprecation).toList.map(p => (p._1, p._2.msg))
304367
def uncheckedWarnings: List[(Position, String)] = summaryMap(Action.WarningSummary, WarningCategory.Unchecked).toList.map(p => (p._1, p._2.msg))
@@ -330,6 +393,91 @@ trait Reporting extends internal.Reporting { self: ast.Positions with Compilatio
330393
if (settings.fatalWarnings.value && reporter.hasWarnings)
331394
reporter.error(NoPosition, "No warnings can be incurred under -Werror.")
332395
}
396+
397+
private object quickfix {
398+
/** Source code at a position. Either a line with caret (offset), else the code at the range position. */
399+
def codeOf(pos: Position, source: SourceFile): String =
400+
if (pos.start < pos.end) new String(source.content.slice(pos.start, pos.end))
401+
else {
402+
val line = source.offsetToLine(pos.point)
403+
val code = source.lines(line).next()
404+
val caret = " " * (pos.point - source.lineToOffset(line)) + "^"
405+
s"$code\n$caret"
406+
}
407+
408+
409+
def checkNoOverlap(patches: List[TextEdit], source: SourceFile): Boolean = {
410+
var ok = true
411+
for (List(p1, p2) <- patches.sliding(2) if p1.position.end > p2.position.start) {
412+
ok = false
413+
val msg =
414+
s"""overlapping quick fixes in ${source.file.file.getAbsolutePath}:
415+
|
416+
|add `${p1.newText}` at
417+
|${codeOf(p1.position, source)}
418+
|
419+
|add `${p2.newText}` at
420+
|${codeOf(p2.position, source)}""".stripMargin.trim
421+
issueWarning(Message.Plain(p1.position, msg, WarningCategory.Other, "", Nil))
422+
}
423+
ok
424+
}
425+
426+
def underlyingFile(source: SourceFile): Option[Path] = {
427+
val fileClass = source.file.getClass.getName
428+
val p = if (fileClass.endsWith("xsbt.ZincVirtualFile")) {
429+
import scala.language.reflectiveCalls
430+
val path = source.file.asInstanceOf[ {def underlying(): {def id(): String}}].underlying().id()
431+
Some(Paths.get(path))
432+
} else
433+
Option(source.file.file).map(_.toPath)
434+
val r = p.filter(Files.exists(_))
435+
if (r.isEmpty)
436+
issueWarning(Message.Plain(NoPosition, s"Failed to apply quick fixes, file does not exist: ${source.file}", WarningCategory.Other, "", Nil))
437+
r
438+
}
439+
440+
val encoding = Charset.forName(settings.encoding.value)
441+
442+
def insertEdits(sourceChars: Array[Char], edits: List[TextEdit], file: Path): Array[Byte] = {
443+
val patchedChars = new Array[Char](sourceChars.length + edits.iterator.map(_.delta).sum)
444+
@tailrec def loop(edits: List[TextEdit], inIdx: Int, outIdx: Int): Unit = {
445+
def copy(upTo: Int): Int = {
446+
val untouched = upTo - inIdx
447+
System.arraycopy(sourceChars, inIdx, patchedChars, outIdx, untouched)
448+
outIdx + untouched
449+
}
450+
edits match {
451+
case e :: es =>
452+
val outNew = copy(e.position.start)
453+
e.newText.copyToArray(patchedChars, outNew)
454+
loop(es, e.position.end, outNew + e.newText.length)
455+
case _ =>
456+
val outNew = copy(sourceChars.length)
457+
if (outNew != patchedChars.length)
458+
issueWarning(Message.Plain(NoPosition, s"Unexpected content length when applying quick fixes; verify the changes to ${file.toFile.getAbsolutePath}", WarningCategory.Other, "", Nil))
459+
}
460+
}
461+
462+
loop(edits, 0, 0)
463+
new String(patchedChars).getBytes(encoding)
464+
}
465+
466+
def apply(edits: mutable.Set[TextEdit]): Unit = {
467+
for ((source, edits) <- edits.groupBy(_.position.source).view.mapValues(_.toList.sortBy(_.position.start))) {
468+
if (checkNoOverlap(edits, source)) {
469+
underlyingFile(source) foreach { file =>
470+
val sourceChars = new String(Files.readAllBytes(file), encoding).toCharArray
471+
try Files.write(file, insertEdits(sourceChars, edits, file))
472+
catch {
473+
case e: IOException =>
474+
issueWarning(Message.Plain(NoPosition, s"Failed to apply quick fixes to ${file.toFile.getAbsolutePath}\n${e.getMessage}", WarningCategory.Other, "", Nil))
475+
}
476+
}
477+
}
478+
}
479+
}
480+
}
333481
}
334482
}
335483

@@ -532,7 +680,8 @@ object Reporting {
532680
}
533681

534682
final case class MessagePattern(pattern: Regex) extends MessageFilter {
535-
def matches(message: Message): Boolean = pattern.findFirstIn(message.msg).nonEmpty
683+
def check(msg: String) = pattern.findFirstIn(msg).nonEmpty
684+
def matches(message: Message): Boolean = check(message.msg)
536685
}
537686

538687
final case class SitePattern(pattern: Regex) extends MessageFilter {
@@ -542,10 +691,11 @@ object Reporting {
542691
final case class SourcePattern(pattern: Regex) extends MessageFilter {
543692
private[this] val cache = mutable.Map.empty[SourceFile, Boolean]
544693

545-
def matches(message: Message): Boolean = cache.getOrElseUpdate(message.pos.source, {
546-
val sourcePath = message.pos.source.file.canonicalPath.replace("\\", "/")
694+
def check(pos: Position) = cache.getOrElseUpdate(pos.source, {
695+
val sourcePath = pos.source.file.canonicalPath.replace("\\", "/")
547696
pattern.findFirstIn(sourcePath).nonEmpty
548697
})
698+
def matches(message: Message): Boolean = check(message.pos)
549699
}
550700

551701
final case class DeprecatedOrigin(pattern: Regex) extends MessageFilter {

src/compiler/scala/tools/nsc/ast/parser/Parsers.scala

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,11 +251,11 @@ self =>
251251
val syntaxErrors = new ListBuffer[(Int, String, List[CodeAction])]
252252
def showSyntaxErrors() =
253253
for ((offset, msg, actions) <- syntaxErrors)
254-
reporter.error(o2p(offset), msg, actions)
254+
runReporting.error(o2p(offset), msg, actions)
255255

256256
override def syntaxError(offset: Offset, msg: String, actions: List[CodeAction]): Unit = {
257257
if (smartParsing) syntaxErrors += ((offset, msg, actions))
258-
else reporter.error(o2p(offset), msg, actions)
258+
else runReporting.error(o2p(offset), msg, actions)
259259
}
260260

261261
override def incompleteInputError(msg: String, actions: List[CodeAction]): Unit = {

src/compiler/scala/tools/nsc/settings/StandardScalaSettings.scala

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,21 @@ trait StandardScalaSettings { _: MutableSettings =>
5151
val nowarn = BooleanSetting ("-nowarn", "Generate no warnings.") withAbbreviation "--no-warnings" withPostSetHook { s => if (s.value) maxwarns.value = 0 }
5252
val optimise: BooleanSetting // depends on post hook which mutates other settings
5353
val print = BooleanSetting ("-print", "Print program with Scala-specific features removed.") withAbbreviation "--print"
54+
val quickfix = MultiStringSetting(
55+
"-quickfix",
56+
"filters",
57+
"Apply quick fixes provided by the compiler for warnings and errors to source files",
58+
helpText = Some(
59+
"""Apply quick fixes provided by the compiler for warnings and errors to source files.
60+
|Syntax: -quickfix:<filter>,...,<filter>
61+
|
62+
|<filter> syntax is the same as for configurable warnings, see `-Wconf:help`. Examples:
63+
| -quickfix:any apply all available quick fixes
64+
| -quickfix:msg=Auto-application apply quick fixes where the message contains "Auto-application"
65+
|
66+
|Use `-Wconf:any:warning-verbose` to display applicable message filters with each warning.
67+
|""".stripMargin),
68+
prepend = true)
5469
val release =
5570
ChoiceSetting("-release", "release", "Compile for a version of the Java API and target class file.", AllTargetVersions, normalizeTarget(javaSpecVersion))
5671
.withPostSetHook { setting =>

src/reflect/scala/reflect/internal/util/CodeAction.scala

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,6 @@ case class CodeAction(title: String, description: Option[String], edits: List[Te
3636
* @groupname Common Commonly used methods
3737
* @group ReflectionAPI
3838
*/
39-
case class TextEdit(position: Position, newText: String)
39+
case class TextEdit(position: Position, newText: String) {
40+
def delta: Int = newText.length - (position.end - position.start)
41+
}

test/files/neg/auto-application.check

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ auto-application.scala:5: error: Int does not take parameters
77
auto-application.scala:6: error: Int does not take parameters
88
("": Object).##()
99
^
10-
auto-application.scala:9: warning: Auto-application to `()` is deprecated. Supply the empty argument list `()` explicitly to invoke method meth,
10+
auto-application.scala:9: warning: [quick fix available] Auto-application to `()` is deprecated. Supply the empty argument list `()` explicitly to invoke method meth,
1111
or remove the empty argument list from its definition (Java-defined methods are exempt).
1212
In Scala 3, an unapplied method like this will be eta-expanded into a function.
1313
meth // warn, auto-application (of nilary methods) is deprecated

test/files/neg/for-comprehension-old.check

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,25 @@
1-
for-comprehension-old.scala:6: error: `val` keyword in for comprehension is unsupported: just remove `val`
1+
for-comprehension-old.scala:6: error: [quick fix available] `val` keyword in for comprehension is unsupported: just remove `val`
22
for (val x <- 1 to 5 ; y = x) yield x+y // fail
33
^
4-
for-comprehension-old.scala:7: error: `val` keyword in for comprehension is unsupported: just remove `val`
4+
for-comprehension-old.scala:7: error: [quick fix available] `val` keyword in for comprehension is unsupported: just remove `val`
55
for (val x <- 1 to 5 ; val y = x) yield x+y // fail
66
^
7-
for-comprehension-old.scala:11: error: `val` keyword in for comprehension is unsupported: just remove `val`
7+
for-comprehension-old.scala:11: error: [quick fix available] `val` keyword in for comprehension is unsupported: just remove `val`
88
for (z <- 1 to 2 ; val x <- 1 to 5 ; y = x) yield x+y // fail
99
^
10-
for-comprehension-old.scala:12: error: `val` keyword in for comprehension is unsupported: just remove `val`
10+
for-comprehension-old.scala:12: error: [quick fix available] `val` keyword in for comprehension is unsupported: just remove `val`
1111
for (z <- 1 to 2 ; val x <- 1 to 5 ; val y = x) yield x+y // fail
1212
^
13-
for-comprehension-old.scala:5: warning: `val` keyword in for comprehension is deprecated: instead, bind the value without `val`
13+
for-comprehension-old.scala:5: warning: [quick fix available] `val` keyword in for comprehension is deprecated: instead, bind the value without `val`
1414
for (x <- 1 to 5 ; val y = x) yield x+y // fail
1515
^
16-
for-comprehension-old.scala:7: warning: `val` keyword in for comprehension is deprecated: instead, bind the value without `val`
16+
for-comprehension-old.scala:7: warning: [quick fix available] `val` keyword in for comprehension is deprecated: instead, bind the value without `val`
1717
for (val x <- 1 to 5 ; val y = x) yield x+y // fail
1818
^
19-
for-comprehension-old.scala:10: warning: `val` keyword in for comprehension is deprecated: instead, bind the value without `val`
19+
for-comprehension-old.scala:10: warning: [quick fix available] `val` keyword in for comprehension is deprecated: instead, bind the value without `val`
2020
for (z <- 1 to 2 ; x <- 1 to 5 ; val y = x) yield x+y // fail
2121
^
22-
for-comprehension-old.scala:12: warning: `val` keyword in for comprehension is deprecated: instead, bind the value without `val`
22+
for-comprehension-old.scala:12: warning: [quick fix available] `val` keyword in for comprehension is deprecated: instead, bind the value without `val`
2323
for (z <- 1 to 2 ; val x <- 1 to 5 ; val y = x) yield x+y // fail
2424
^
2525
4 warnings

0 commit comments

Comments
 (0)