@@ -14,12 +14,15 @@ package scala
1414package tools
1515package nsc
1616
17+ import java .io .IOException
18+ import java .nio .charset .Charset
19+ import java .nio .file .{Files ,Path ,Paths }
1720import java .util .regex .PatternSyntaxException
18- import scala .annotation .nowarn
21+ import scala .annotation .{ nowarn , tailrec }
1922import scala .collection .mutable
2023import scala .reflect .internal
2124import 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 }
2326import scala .tools .nsc .Reporting .Version .{NonParseableVersion ,ParseableVersion }
2427import scala .tools .nsc .Reporting ._
2528import scala .tools .nsc .settings .NoScalaVersion
@@ -62,13 +65,50 @@ trait Reporting extends internal.Reporting { self: ast.Positions with Compilatio
6265else 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+
65103private val summarizedWarnings : mutable.Map [WarningCategory , mutable.LinkedHashMap [Position ,Message ]]= mutable.HashMap .empty
66104private val summarizedInfos : mutable.Map [WarningCategory , mutable.LinkedHashMap [Position ,Message ]]= mutable.HashMap .empty
67105
68106private val suppressions : mutable.LinkedHashMap [SourceFile , mutable.ListBuffer [Suppression ]]= mutable.LinkedHashMap .empty
69107private val suppressionsComplete : mutable.Set [SourceFile ]= mutable.Set .empty
70108private 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.
73113def 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
105149def reportSuspendedMessages (unit :CompilationUnit ): Unit = {
@@ -119,6 +163,14 @@ trait Reporting extends internal.Reporting { self: ast.Positions with Compilatio
119163 }
120164
121165private 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+
122174def ifNonEmpty (kind :String ,filter :String )= if (filter.nonEmpty)s " , $kind= $filter" else " "
123175def filterHelp =
124176s " msg=<part of the message>, cat= ${warning.category.name}" +
@@ -133,12 +185,13 @@ trait Reporting extends internal.Reporting { self: ast.Positions with Compilatio
133185" \n Scala 3 migration messages are errors under -Xsource:3. Use -Wconf / @nowarn to filter them or add -Xmigration to demote them to warnings."
134186else " "
135187def helpMsg (kind :String ,isError :Boolean = false )=
136- s " ${warning.msg}${scala3migration(isError)}\n Applicable -Wconf / @nowarn filters for this $kind: $filterHelp"
137- wconf.action(warning)match {
188+ s " $quickfixed${scala3migration(isError)}\n Applicable -Wconf / @nowarn filters for this $kind: $filterHelp"
189+
190+ actionmatch {
138191case 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)
140193case 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)
142195case Action .InfoVerbose => reporter.echo(warning.pos, helpMsg(" message" ), warning.actions)
143196case a@ (Action .WarningSummary | Action .InfoSummary )=>
144197val m = summaryMap(a, warning.category.summaryCategory)
@@ -299,6 +352,16 @@ trait Reporting extends internal.Reporting { self: ast.Positions with Compilatio
299352def 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
303366def deprecationWarnings : List [(Position ,String )]= summaryMap(Action .WarningSummary ,WarningCategory .Deprecation ).toList.map(p=> (p._1, p._2.msg))
304367def 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
330393if (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+ @ tailrecdef 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+ editsmatch {
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
534682final 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
538687final case class SitePattern (pattern :Regex )extends MessageFilter {
@@ -542,10 +691,11 @@ object Reporting {
542691final case class SourcePattern (pattern :Regex )extends MessageFilter {
543692private [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
551701final case class DeprecatedOrigin (pattern :Regex )extends MessageFilter {