diff --git a/build.sbt b/build.sbt index 8bee9215b..66c1ca96b 100644 --- a/build.sbt +++ b/build.sbt @@ -9,6 +9,20 @@ inThisBuild( noPublish +lazy val interfaces = project + .in(file("scalafix-interfaces")) + .settings( + javaHome.in(Compile) := { + // force javac to fork by setting javaHome to get error messages during compilation, + // see https://github.com/sbt/zinc/issues/520 + Some(file(sys.props("java.home")).getParentFile) + }, + moduleName := "scalafix-interfaces", + crossVersion := CrossVersion.disabled, + crossScalaVersions := List(scala212), + autoScalaLibrary := false + ) + lazy val core = project .in(file("scalafix-core")) .settings( @@ -51,7 +65,7 @@ lazy val cli = project "org.apache.commons" % "commons-text" % "1.2" ) ) - .dependsOn(reflect) + .dependsOn(reflect, interfaces) lazy val testsShared = project .in(file("scalafix-tests/shared")) @@ -120,9 +134,10 @@ lazy val unit = project javaOptions := Nil, buildInfoPackage := "scalafix.tests", buildInfoObject := "BuildInfo", - libraryDependencies ++= coursierDeps ++ testsDeps, + libraryDependencies ++= testsDeps, libraryDependencies ++= List( jgit, + semanticdbPluginLibrary, scalatest ), compileInputs.in(Compile, compile) := { diff --git a/project/Dependencies.scala b/project/Dependencies.scala index cd980f0b6..319d2a766 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -22,6 +22,7 @@ object Dependencies { def googleDiff = "com.googlecode.java-diff-utils" % "diffutils" % "1.3.0" def metacp = "org.scalameta" %% "metacp" % scalametaV + def semanticdbPluginLibrary = "org.scalameta" % "semanticdb-scalac-core" % scalametaV cross CrossVersion.full def scalameta = "org.scalameta" %% "contrib" % scalametaV def symtab = "org.scalameta" %% "symtab" % scalametaV def scalatest = "org.scalatest" %% "scalatest" % "3.2.0-SNAP10" @@ -29,6 +30,7 @@ object Dependencies { def testsDeps = List( // integration property tests + "com.geirsson" %% "coursier-small" % "1.0.0-M4", "org.renucci" %% "scala-xml-quote" % "0.1.4", "org.typelevel" %% "catalysts-platform" % "0.0.5", "org.typelevel" %% "cats-core" % "0.9.0", diff --git a/project/ScalafixBuild.scala b/project/ScalafixBuild.scala index 7d901f450..9e5c2c194 100644 --- a/project/ScalafixBuild.scala +++ b/project/ScalafixBuild.scala @@ -23,9 +23,7 @@ object ScalafixBuild extends AutoPlugin with GhpagesKeys { lazy val noPublish = Seq( mimaReportBinaryIssues := {}, mimaPreviousArtifacts := Set.empty, - publishArtifact := false, - publish := {}, - publishLocal := {} + skip in publish := true ) lazy val supportedScalaVersions = List(scala211, scala212) lazy val isFullCrossVersion = Seq( @@ -33,6 +31,7 @@ object ScalafixBuild extends AutoPlugin with GhpagesKeys { ) lazy val warnUnusedImports = "-Ywarn-unused-import" lazy val compilerOptions = Seq( + "-target:jvm-1.8", warnUnusedImports, "-deprecation", "-encoding", diff --git a/scalafix-cli/src/main/scala/scalafix/cli/ExitStatus.scala b/scalafix-cli/src/main/scala/scalafix/cli/ExitStatus.scala index fdab05644..268769eea 100644 --- a/scalafix-cli/src/main/scala/scalafix/cli/ExitStatus.scala +++ b/scalafix-cli/src/main/scala/scalafix/cli/ExitStatus.scala @@ -4,6 +4,7 @@ import scala.collection.mutable sealed abstract case class ExitStatus(code: Int, name: String) { def isOk: Boolean = code == ExitStatus.Ok.code + def is(exit: ExitStatus): Boolean = (code & exit.code) != 0 override def toString: String = s"$name=$code" } @@ -26,7 +27,6 @@ object ExitStatus { val Ok, UnexpectedError, ParseError, - ScalafixError, CommandLineError, MissingSemanticdbError, StaleSemanticdbError, diff --git a/scalafix-cli/src/main/scala/scalafix/internal/interfaces/MainCallbackImpl.scala b/scalafix-cli/src/main/scala/scalafix/internal/interfaces/MainCallbackImpl.scala new file mode 100644 index 000000000..5b0cfdffc --- /dev/null +++ b/scalafix-cli/src/main/scala/scalafix/internal/interfaces/MainCallbackImpl.scala @@ -0,0 +1,46 @@ +package scalafix.internal.interfaces + +import scala.meta.inputs.Position +import scalafix.interfaces.ScalafixDiagnostic +import scalafix.interfaces.ScalafixMainCallback +import scalafix.internal.config +import scalafix.internal.config.ScalafixReporter +import scalafix.internal.util.LintSyntax +import scalafix.lint.LintDiagnostic +import scalafix.lint.LintID +import scalafix.lint.LintSeverity + +object MainCallbackImpl { + + def default: ScalafixMainCallback = fromScala(config.ScalafixReporter.default) + + def fromScala(underlying: config.ScalafixReporter): ScalafixMainCallback = + new ScalafixMainCallback { + override def reportDiagnostic(d: ScalafixDiagnostic): Unit = { + val diagnostic = ScalafixDiagnosticImpl.fromJava(d) + if (diagnostic.id == LintID.empty) { + underlying.report( + diagnostic.message, + diagnostic.position, + diagnostic.severity) + } else { + underlying.lint(diagnostic) + } + } + } + + def fromJava(underlying: ScalafixMainCallback): ScalafixReporter = + new ScalafixReporter { + override def lint(d: LintDiagnostic): Unit = { + val diagnostic = ScalafixDiagnosticImpl.fromScala(d) + underlying.reportDiagnostic(diagnostic) + } + def report(msg: String, pos: Position, sev: LintSeverity): Unit = { + val diagnostic = ScalafixDiagnosticImpl.fromScala( + new LintSyntax.EagerLintDiagnostic(msg, pos, sev, "", LintID.empty) + ) + underlying.reportDiagnostic(diagnostic) + } + } + +} diff --git a/scalafix-cli/src/main/scala/scalafix/internal/interfaces/PositionImpl.scala b/scalafix-cli/src/main/scala/scalafix/internal/interfaces/PositionImpl.scala new file mode 100644 index 000000000..96de6c81e --- /dev/null +++ b/scalafix-cli/src/main/scala/scalafix/internal/interfaces/PositionImpl.scala @@ -0,0 +1,26 @@ +package scalafix.internal.interfaces + +import java.util.Optional +import scala.meta.inputs.Position +import scalafix.interfaces.ScalafixInput +import scalafix.interfaces.ScalafixPosition +import scalafix.internal.util.PositionSyntax._ + +object PositionImpl { + def optionalFromScala(pos: Position): Optional[ScalafixPosition] = + if (pos == Position.None) Optional.empty() + else Optional.of(fromScala(pos)) + def fromScala(pos: Position): ScalafixPosition = + new ScalafixPosition { + override def formatMessage(severity: String, message: String): String = + pos.formatMessage(severity, message) + override def startOffset(): Int = pos.start + override def startLine(): Int = pos.startLine + override def startColumn(): Int = pos.startColumn + override def endOffset(): Int = pos.end + override def endLine(): Int = pos.endLine + override def endColumn(): Int = pos.endColumn + override def input(): ScalafixInput = + ScalafixInputImpl.fromScala(pos.input) + } +} diff --git a/scalafix-cli/src/main/scala/scalafix/internal/interfaces/ScalafixDiagnosticImpl.scala b/scalafix-cli/src/main/scala/scalafix/internal/interfaces/ScalafixDiagnosticImpl.scala new file mode 100644 index 000000000..b6a494ce2 --- /dev/null +++ b/scalafix-cli/src/main/scala/scalafix/internal/interfaces/ScalafixDiagnosticImpl.scala @@ -0,0 +1,66 @@ +package scalafix.internal.interfaces +import java.util.Optional +import scala.meta.inputs.Input +import scala.meta.inputs.Position +import scalafix.interfaces.ScalafixDiagnostic +import scalafix.interfaces.ScalafixLintID +import scalafix.interfaces.ScalafixPosition +import scalafix.interfaces.ScalafixSeverity +import scalafix.lint.LintDiagnostic +import scalafix.lint.LintID +import scalafix.lint.LintSeverity + +object ScalafixDiagnosticImpl { + def fromScala(diagnostic: LintDiagnostic): ScalafixDiagnostic = + new ScalafixDiagnostic { + override def severity(): ScalafixSeverity = diagnostic.severity match { + case LintSeverity.Info => ScalafixSeverity.INFO + case LintSeverity.Warning => ScalafixSeverity.WARNING + case LintSeverity.Error => ScalafixSeverity.ERROR + } + override def message(): String = diagnostic.message + override def explanation(): String = diagnostic.explanation + override def position(): Optional[ScalafixPosition] = + PositionImpl.optionalFromScala(diagnostic.position) + override def lintID(): Optional[ScalafixLintID] = + if (diagnostic.id == LintID.empty) { + Optional.empty() + } else { + Optional.of(new ScalafixLintID { + override def ruleName(): String = diagnostic.id.rule + override def categoryID(): String = diagnostic.id.categoryID + }) + } + } + + def fromJava(diagnostic: ScalafixDiagnostic): LintDiagnostic = + new LintDiagnostic { + override def message: String = diagnostic.message() + override def position: Position = { + if (diagnostic.position().isPresent) { + val spos = diagnostic.position().get + val input = Input.VirtualFile( + spos.input().filename(), + spos.input().text().toString + ) + Position.Range(input, spos.startOffset(), spos.endOffset()) + } else { + Position.None + } + } + override def severity: LintSeverity = diagnostic.severity() match { + case ScalafixSeverity.INFO => LintSeverity.Info + case ScalafixSeverity.WARNING => LintSeverity.Warning + case ScalafixSeverity.ERROR => LintSeverity.Error + } + override def explanation: String = diagnostic.explanation() + override def id: LintID = { + if (diagnostic.lintID().isPresent) { + val lintID = diagnostic.lintID().get + LintID(lintID.ruleName(), lintID.categoryID()) + } else { + LintID.empty + } + } + } +} diff --git a/scalafix-cli/src/main/scala/scalafix/internal/interfaces/ScalafixErrorImpl.scala b/scalafix-cli/src/main/scala/scalafix/internal/interfaces/ScalafixErrorImpl.scala new file mode 100644 index 000000000..b1389b52f --- /dev/null +++ b/scalafix-cli/src/main/scala/scalafix/internal/interfaces/ScalafixErrorImpl.scala @@ -0,0 +1,30 @@ +package scalafix.internal.interfaces +import scalafix.cli.ExitStatus +import scalafix.interfaces.ScalafixError + +object ScalafixErrorImpl { + private lazy val statusToError: Map[ExitStatus, ScalafixError] = { + val ok :: from = ExitStatus.all + assert(ok.isOk) + val to = ScalafixError.values().toList + assert(from.length == to.length, s"$from != $to") + val map = from.zip(to).toMap + map.foreach { + case (key, value) => + assert( + key.name.toLowerCase() == value.toString.toLowerCase, + s"$key != $value" + ) + } + map + } + + def fromScala(exit: ExitStatus): Array[ScalafixError] = { + val buf = Array.newBuilder[ScalafixError] + ExitStatus.all.foreach { code => + if (exit.is(code)) + buf += statusToError(code) + } + buf.result() + } +} diff --git a/scalafix-cli/src/main/scala/scalafix/internal/interfaces/ScalafixImpl.scala b/scalafix-cli/src/main/scala/scalafix/internal/interfaces/ScalafixImpl.scala new file mode 100644 index 000000000..e437faa60 --- /dev/null +++ b/scalafix-cli/src/main/scala/scalafix/internal/interfaces/ScalafixImpl.scala @@ -0,0 +1,37 @@ +package scalafix.internal.interfaces + +import scalafix.Versions +import scalafix.interfaces.Scalafix +import scalafix.interfaces.ScalafixError +import scalafix.interfaces.ScalafixMainArgs +import scalafix.internal.v1.MainOps + +final class ScalafixImpl extends Scalafix { + + override def toString: String = + s"""Scalafix v${scalafixVersion()}""" + + override def runMain(args: ScalafixMainArgs): Array[ScalafixError] = { + val exit = + MainOps.run(Array(), args.asInstanceOf[ScalafixMainArgsImpl].args) + ScalafixErrorImpl.fromScala(exit) + } + + override def newMainArgs(): ScalafixMainArgs = + ScalafixMainArgsImpl() + + override def mainHelp(screenWidth: Int): String = { + MainOps.helpMessage(screenWidth) + } + + override def scalafixVersion(): String = + Versions.version + override def scalametaVersion(): String = + Versions.scalameta + override def supportedScalaVersions(): Array[String] = + Versions.supportedScalaVersions.toArray + override def scala211(): String = + Versions.scala211 + override def scala212(): String = + Versions.scala212 +} diff --git a/scalafix-cli/src/main/scala/scalafix/internal/interfaces/ScalafixInputImpl.scala b/scalafix-cli/src/main/scala/scalafix/internal/interfaces/ScalafixInputImpl.scala new file mode 100644 index 000000000..380e73220 --- /dev/null +++ b/scalafix-cli/src/main/scala/scalafix/internal/interfaces/ScalafixInputImpl.scala @@ -0,0 +1,23 @@ +package scalafix.internal.interfaces + +import java.nio.CharBuffer +import java.nio.file.Path +import java.util.Optional +import scala.meta.inputs.Input +import scala.{meta => m} +import scalafix.interfaces.ScalafixInput + +object ScalafixInputImpl { + def fromScala(input: m.Input): ScalafixInput = + new ScalafixInput { + override def text(): CharSequence = input match { + case Input.VirtualFile(_, value) => value + case _ => CharBuffer.wrap(input.chars) + } + override def filename(): String = input.syntax + override def path(): Optional[Path] = input match { + case Input.File(path, _) => Optional.of(path.toNIO) + case _ => Optional.empty() + } + } +} diff --git a/scalafix-cli/src/main/scala/scalafix/internal/interfaces/ScalafixMainArgsImpl.scala b/scalafix-cli/src/main/scala/scalafix/internal/interfaces/ScalafixMainArgsImpl.scala new file mode 100644 index 000000000..f533704c8 --- /dev/null +++ b/scalafix-cli/src/main/scala/scalafix/internal/interfaces/ScalafixMainArgsImpl.scala @@ -0,0 +1,88 @@ +package scalafix.internal.interfaces + +import java.io.PrintStream +import java.net.URLClassLoader +import java.nio.charset.Charset +import java.nio.file.Path +import java.nio.file.PathMatcher +import java.util +import metaconfig.Conf +import scala.meta.io.AbsolutePath +import scala.meta.io.Classpath +import scalafix.interfaces.ScalafixMainArgs +import scalafix.interfaces.ScalafixMainCallback +import scalafix.interfaces.ScalafixMainMode +import scalafix.internal.v1.Args +import scala.collection.JavaConverters._ + +final case class ScalafixMainArgsImpl(args: Args = Args.default) + extends ScalafixMainArgs { + + override def withRules(rules: util.List[String]): ScalafixMainArgs = + copy(args = args.copy(rules = rules.asScala.toList)) + + override def withToolClasspath( + classLoader: URLClassLoader): ScalafixMainArgs = + copy(args = args.copy(toolClasspath = classLoader)) + + override def withPaths(paths: util.List[Path]): ScalafixMainArgs = + copy( + args = args.copy( + files = paths.asScala.iterator.map(AbsolutePath(_)(args.cwd)).toList) + ) + + override def withExcludedPaths( + matchers: util.List[PathMatcher]): ScalafixMainArgs = + copy(args = args.copy(exclude = matchers.asScala.toList)) + + override def withWorkingDirectory(path: Path): ScalafixMainArgs = { + require(path.isAbsolute, s"working directory must be relative: $path") + copy(args = args.copy(cwd = AbsolutePath(path))) + } + + override def withConfig(path: Path): ScalafixMainArgs = + copy(args = args.copy(config = Some(AbsolutePath(path)(args.cwd)))) + + override def withMode(mode: ScalafixMainMode): ScalafixMainArgs = mode match { + case ScalafixMainMode.TEST => + copy(args = args.copy(test = true)) + case ScalafixMainMode.IN_PLACE => + copy(args = args.copy(stdout = false)) + case ScalafixMainMode.STDOUT => + copy(args = args.copy(stdout = true)) + case ScalafixMainMode.AUTO_SUPPRESS_LINTER_ERRORS => + copy(args = args.copy(autoSuppressLinterErrors = true)) + } + + override def withArgs(args: util.List[String]): ScalafixMainArgs = { + val decoder = Args.decoder(this.args) + val newArgs = Conf + .parseCliArgs[Args](args.asScala.toList) + .andThen(c => c.as[Args](decoder)) + .get + copy(args = newArgs) + } + + override def withPrintStream(out: PrintStream): ScalafixMainArgs = + copy(args = args.copy(out = out)) + + override def withClasspath(path: util.List[Path]): ScalafixMainArgs = + copy( + args = args.copy( + classpath = Classpath( + path.asScala.iterator.map(AbsolutePath(_)(args.cwd)).toList)) + ) + + override def withSourceroot(path: Path): ScalafixMainArgs = { + require(path.isAbsolute, s"sourceroot must be relative: $path") + copy(args = args.copy(sourceroot = Some(AbsolutePath(path)(args.cwd)))) + } + + override def withMainCallback( + callback: ScalafixMainCallback): ScalafixMainArgs = + copy(args = args.copy(callback = callback)) + + override def withCharset(charset: Charset): ScalafixMainArgs = + copy(args = args.copy(charset = charset)) + +} diff --git a/scalafix-cli/src/main/scala/scalafix/internal/v1/Args.scala b/scalafix-cli/src/main/scala/scalafix/internal/v1/Args.scala index 88b5b2352..c78c4763b 100644 --- a/scalafix-cli/src/main/scala/scalafix/internal/v1/Args.scala +++ b/scalafix-cli/src/main/scala/scalafix/internal/v1/Args.scala @@ -4,6 +4,7 @@ import scala.language.higherKinds import java.io.File import java.io.PrintStream import java.net.URI +import java.net.URLClassLoader import java.nio.charset.Charset import java.nio.charset.StandardCharsets import java.nio.file.FileSystems @@ -22,12 +23,14 @@ import scala.annotation.StaticAnnotation import scala.meta.internal.io.PathIO import scala.meta.io.AbsolutePath import scala.meta.io.Classpath -import scalafix.internal.config.OutputFormat import scalafix.internal.config.ScalafixConfig import scalafix.internal.diff.DiffDisable import scalafix.internal.jgit.JGitDiff import scalafix.internal.reflect.ClasspathOps import scala.meta.internal.symtab.SymbolTable +import scalafix.interfaces.ScalafixMainCallback +import scalafix.internal.config.PrintStreamReporter +import scalafix.internal.interfaces.MainCallbackImpl import scalafix.v1.RuleDecoder class Section(val name: String) extends StaticAnnotation @@ -79,12 +82,6 @@ case class Args( "Relative filenames persisted in the Semantic DB are absolutized by the " + "sourceroot. Defaults to current working directory if not provided.") sourceroot: Option[AbsolutePath] = None, - @Description( - "Global cache location to persist metacp artifacts produced by analyzing --dependency-classpath. " + - "The default location depends on the OS and is computed with https://github.com/soc/directories-jvm " + - "using the project name 'semanticdb'. " + - "On macOS the default cache directory is ~/Library/Caches/semanticdb. ") - metacpCacheDir: Option[AbsolutePath] = None, @Description( "If set, automatically infer the --classpath flag by scanning for directories with META-INF/semanticdb") autoClasspath: Boolean = false, @@ -119,7 +116,7 @@ case class Args( exclude: List[PathMatcher] = Nil, @Description( "Additional classpath for compiling and classloading custom rules.") - toolClasspath: Classpath = Classpath(Nil), + toolClasspath: URLClassLoader = ClasspathOps.thisClassLoader, @Description("The encoding to use for reading/writing files") charset: Charset = StandardCharsets.UTF_8, @Description("If set, throw exception in the end instead of System.exit") @@ -128,8 +125,6 @@ case class Args( noStaleSemanticdb: Boolean = false, @Description("Custom settings to override .scalafix.conf") settings: Conf = Conf.Obj.empty, - @Description("The format for console output") - format: OutputFormat = OutputFormat.Default, @Description( "Write fixed output to custom location instead of in-place. Regex is passed as first argument to file.replaceAll(--out-from, --out-to), requires --out-to.") outFrom: Option[String] = None, @@ -147,13 +142,16 @@ case class Args( @Hidden out: PrintStream, @Hidden - ls: Ls = Ls.Find + ls: Ls = Ls.Find, + @Hidden + callback: ScalafixMainCallback ) { + override def toString: String = ConfEncoder[Args].write(this).toString() + def configuredSymtab: Configured[SymbolTable] = { ClasspathOps.newSymbolTable( classpath = classpath, - cacheDirectory = metacpCacheDir, out = out ) match { case Some(symtab) => @@ -163,7 +161,7 @@ case class Args( } } - def baseConfig: Configured[(Conf, ScalafixConfig)] = { + def baseConfig: Configured[(Conf, ScalafixConfig, DelegatingMainCallback)] = { val toRead: Option[AbsolutePath] = config.orElse { val defaultPath = cwd.resolve(".scalafix.conf") if (defaultPath.isFile) Some(defaultPath) @@ -183,7 +181,9 @@ case class Args( base.andThen { b => val applied = Conf.applyPatch(b, settings) applied.as[ScalafixConfig].map { scalafixConfig => - applied -> scalafixConfig.withOut(out) + val delegator = new DelegatingMainCallback(callback) + val reporter = MainCallbackImpl.fromJava(delegator) + (applied, scalafixConfig.copy(reporter = reporter), delegator) } } } @@ -204,7 +204,7 @@ case class Args( val decoderSettings = RuleDecoder .Settings() .withConfig(scalafixConfig) - .withToolClasspath(toolClasspath.entries) + .withToolClasspath(toolClasspath) .withCwd(cwd) val decoder = RuleDecoder.decoder(decoderSettings) decoder.read(rulesConf).andThen(_.withConfig(base)) @@ -261,15 +261,16 @@ case class Args( if (autoClasspathRoots.isEmpty) cwd :: Nil else autoClasspathRoots ClasspathOps.autoClasspath(roots) - } else classpath + } else { + classpath + } } - def classLoader: ClassLoader = - ClasspathOps.toClassLoader(validatedClasspath) + def classLoader: ClassLoader = ClasspathOps.toClassLoader(validatedClasspath) def validate: Configured[ValidatedArgs] = { baseConfig.andThen { - case (base, scalafixConfig) => + case (base, scalafixConfig, delegator) => ( configuredSourceroot |@| configuredSymtab |@| @@ -282,13 +283,12 @@ case class Args( this, symtab, rulez, - scalafixConfig.withFormat( - format - ), + scalafixConfig, classLoader, root, pathReplace, - diffDisable + diffDisable, + delegator ) } } @@ -298,21 +298,26 @@ case class Args( object Args { val baseMatcher: PathMatcher = FileSystems.getDefault.getPathMatcher("glob:**.{scala,sbt}") - val default = new Args(cwd = PathIO.workingDirectory, out = System.out) + val default: Args = default(PathIO.workingDirectory, System.out) + def default(cwd: AbsolutePath, out: PrintStream): Args = { + val callback = MainCallbackImpl.fromScala(PrintStreamReporter(out)) + new Args(cwd = cwd, out = out, callback = callback) + } - def decoder(cwd: AbsolutePath, out: PrintStream): ConfDecoder[Args] = { + def decoder(base: Args): ConfDecoder[Args] = { implicit val classpathDecoder: ConfDecoder[Classpath] = ConfDecoder.stringConfDecoder.map { cp => Classpath( cp.split(File.pathSeparator) .iterator - .map(path => AbsolutePath(path)(cwd)) + .map(path => AbsolutePath(path)(base.cwd)) .toList ) } + implicit val classLoaderDecoder: ConfDecoder[URLClassLoader] = + ConfDecoder[Classpath].map(ClasspathOps.toClassLoader) implicit val absolutePathDecoder: ConfDecoder[AbsolutePath] = - ConfDecoder.stringConfDecoder.map(AbsolutePath(_)(cwd)) - val base = new Args(cwd = cwd, out = out) + ConfDecoder.stringConfDecoder.map(AbsolutePath(_)(base.cwd)) generic.deriveDecoder(base) } @@ -323,6 +328,8 @@ object Args { implicit val pathMatcherDecoder: ConfDecoder[PathMatcher] = ConfDecoder.stringConfDecoder.map(glob => FileSystems.getDefault.getPathMatcher("glob:" + glob)) + implicit val callbackDecoder: ConfDecoder[ScalafixMainCallback] = + ConfDecoder.stringConfDecoder.map(_ => MainCallbackImpl.default) implicit val confEncoder: ConfEncoder[Conf] = ConfEncoder.ConfEncoder @@ -330,12 +337,16 @@ object Args { ConfEncoder.StringEncoder.contramap(_.toString()) implicit val classpathEncoder: ConfEncoder[Classpath] = ConfEncoder.StringEncoder.contramap(_.toString()) + implicit val classLoaderEncoder: ConfEncoder[URLClassLoader] = + ConfEncoder.StringEncoder.contramap(_.toString()) implicit val charsetEncoder: ConfEncoder[Charset] = ConfEncoder.StringEncoder.contramap(_.name()) implicit val printStreamEncoder: ConfEncoder[PrintStream] = ConfEncoder.StringEncoder.contramap(_ => "") implicit val pathMatcherEncoder: ConfEncoder[PathMatcher] = ConfEncoder.StringEncoder.contramap(_.toString) + implicit val callbackEncoder: ConfEncoder[ScalafixMainCallback] = + ConfEncoder.StringEncoder.contramap(_.toString) implicit val argsEncoder: ConfEncoder[Args] = generic.deriveEncoder implicit val absolutePathPrint: TPrint[AbsolutePath] = @@ -344,9 +355,6 @@ object Args { TPrint.make[PathMatcher](_ => "") implicit val confPrint: TPrint[Conf] = TPrint.make[Conf](implicit cfg => TPrint.implicitly[ScalafixConfig].render) - implicit val outputFormat: TPrint[OutputFormat] = - TPrint.make[OutputFormat](implicit cfg => - OutputFormat.all.map(_.toString.toLowerCase).mkString("<", "|", ">")) implicit def optionPrint[T]( implicit ev: pprint.TPrint[T]): TPrint[Option[T]] = TPrint.make { implicit cfg => diff --git a/scalafix-cli/src/main/scala/scalafix/internal/v1/DelegatingMainCallback.scala b/scalafix-cli/src/main/scala/scalafix/internal/v1/DelegatingMainCallback.scala new file mode 100644 index 000000000..312e2f73f --- /dev/null +++ b/scalafix-cli/src/main/scala/scalafix/internal/v1/DelegatingMainCallback.scala @@ -0,0 +1,24 @@ +package scalafix.internal.v1 + +import java.util.concurrent.atomic.AtomicInteger +import scalafix.interfaces +import scalafix.interfaces.ScalafixDiagnostic +import scalafix.interfaces.ScalafixMainCallback + +final class DelegatingMainCallback(underlying: ScalafixMainCallback) + extends ScalafixMainCallback { + private val lintErrorCount = new AtomicInteger() + private val normalErrorCount = new AtomicInteger() + def hasErrors: Boolean = normalErrorCount.get() > 0 + def hasLintErrors: Boolean = lintErrorCount.get() > 0 + override def reportDiagnostic(diagnostic: ScalafixDiagnostic): Unit = { + if (diagnostic.severity() == interfaces.ScalafixSeverity.ERROR) { + if (diagnostic.lintID().isPresent) { + lintErrorCount.incrementAndGet() + } else { + normalErrorCount.incrementAndGet() + } + } + underlying.reportDiagnostic(diagnostic) + } +} diff --git a/scalafix-cli/src/main/scala/scalafix/internal/v1/MainOps.scala b/scalafix-cli/src/main/scala/scalafix/internal/v1/MainOps.scala index c8c0ff67a..c07ed88aa 100644 --- a/scalafix-cli/src/main/scala/scalafix/internal/v1/MainOps.scala +++ b/scalafix-cli/src/main/scala/scalafix/internal/v1/MainOps.scala @@ -11,6 +11,7 @@ import java.nio.file.SimpleFileVisitor import java.nio.file.attribute.BasicFileAttributes import metaconfig.Conf import metaconfig.ConfEncoder +import metaconfig.Configured import metaconfig.annotation.Hidden import metaconfig.annotation.Inline import metaconfig.generic.Setting @@ -27,13 +28,45 @@ import scala.util.control.NoStackTrace import scala.util.control.NonFatal import scalafix.Versions import scalafix.cli.ExitStatus +import scalafix.internal.config.PrintStreamReporter import scalafix.internal.diff.DiffUtils -import scalafix.lint.LintMessage +import scalafix.lint.LintDiagnostic import scalafix.v1.Doc import scalafix.v1.SemanticDoc object MainOps { + def run(args: Array[String], base: Args): ExitStatus = { + val out = base.out + Conf + .parseCliArgs[Args](args.toList) + .andThen(c => c.as[Args](Args.decoder(base))) + .andThen(_.validate) match { + case Configured.Ok(validated) => + if (validated.args.help) { + MainOps.helpMessage(out, 80) + ExitStatus.Ok + } else if (validated.args.version) { + out.println(Versions.version) + ExitStatus.Ok + } else if (validated.args.bash) { + out.println(CompletionsOps.bashCompletions) + ExitStatus.Ok + } else if (validated.args.zsh) { + out.println(CompletionsOps.zshCompletions) + ExitStatus.Ok + } else if (validated.rules.isEmpty) { + out.println("Missing --rules") + ExitStatus.CommandLineError + } else { + MainOps.run(validated) + } + case Configured.NotOk(err) => + PrintStreamReporter(out = out).error(err.toString()) + ExitStatus.CommandLineError + } + } + def files(args: ValidatedArgs): Seq[AbsolutePath] = args.args.ls match { case Ls.Find => val buf = ArrayBuffer.empty[AbsolutePath] @@ -86,9 +119,9 @@ object MainOps { code: ExitStatus, files: Seq[AbsolutePath] ): ExitStatus = { - if (args.config.lint.reporter.hasErrors) { + if (args.callback.hasLintErrors) { ExitStatus.merge(ExitStatus.LinterError, code) - } else if (args.config.reporter.hasErrors && code.isOk) { + } else if (args.callback.hasErrors && code.isOk) { ExitStatus.merge(ExitStatus.UnexpectedError, code) } else if (files.isEmpty) { args.config.reporter.error("No files to fix") @@ -98,14 +131,11 @@ object MainOps { } } - def reportLintErrors(args: ValidatedArgs, messages: List[LintMessage]): Unit = - messages.foreach { msg => - val category = msg.category.withConfig(args.config.lint) - args.config.lint.reporter.handleMessage( - msg.format(args.config.lint.explain), - msg.position, - category.severity.toSeverity - ) + def reportLintErrors( + args: ValidatedArgs, + messages: List[LintDiagnostic]): Unit = + messages.foreach { diag => + args.config.reporter.lint(diag) } def assertFreshSemanticDB( @@ -175,7 +205,9 @@ object MainOps { } if (!args.args.autoSuppressLinterErrors) { - reportLintErrors(args, messages) + messages.foreach { diag => + args.config.reporter.lint(diag) + } } if (args.args.test) { diff --git a/scalafix-cli/src/main/scala/scalafix/internal/v1/ValidatedArgs.scala b/scalafix-cli/src/main/scala/scalafix/internal/v1/ValidatedArgs.scala index 0ef835c23..149dc58f6 100644 --- a/scalafix-cli/src/main/scala/scalafix/internal/v1/ValidatedArgs.scala +++ b/scalafix-cli/src/main/scala/scalafix/internal/v1/ValidatedArgs.scala @@ -18,7 +18,8 @@ case class ValidatedArgs( classLoader: ClassLoader, sourceroot: AbsolutePath, pathReplace: AbsolutePath => AbsolutePath, - diffDisable: DiffDisable + diffDisable: DiffDisable, + callback: DelegatingMainCallback ) { def input(file: AbsolutePath): Input = { diff --git a/scalafix-cli/src/main/scala/scalafix/v1/Main.scala b/scalafix-cli/src/main/scala/scalafix/v1/Main.scala index 87509927e..152fdc685 100644 --- a/scalafix-cli/src/main/scala/scalafix/v1/Main.scala +++ b/scalafix-cli/src/main/scala/scalafix/v1/Main.scala @@ -4,12 +4,8 @@ import com.martiansoftware.nailgun.NGContext import java.io.PrintStream import java.nio.file.Path import java.nio.file.Paths -import metaconfig.Conf -import metaconfig.Configured import scala.meta.io.AbsolutePath -import scalafix.Versions import scalafix.cli.ExitStatus -import scalafix.internal.config.ScalafixReporter import scalafix.internal.v1._ object Main { @@ -32,38 +28,8 @@ object Main { } } - def run(args: Array[String], cwd: Path, out: PrintStream): ExitStatus = - Conf - .parseCliArgs[Args](args.toList) - .andThen(c => c.as[Args](Args.decoder(AbsolutePath(cwd), out))) - .andThen(_.validate) match { - case Configured.Ok(validated) => - if (validated.args.help) { - MainOps.helpMessage(out, 80) - ExitStatus.Ok - } else if (validated.args.version) { - out.println(Versions.version) - ExitStatus.Ok - } else if (validated.args.bash) { - out.println(CompletionsOps.bashCompletions) - ExitStatus.Ok - } else if (validated.args.zsh) { - out.println(CompletionsOps.zshCompletions) - ExitStatus.Ok - } else if (validated.rules.isEmpty) { - out.println("Missing --rules") - ExitStatus.CommandLineError - } else { - val adjusted = validated.copy( - args = validated.args.copy( - out = out, - cwd = AbsolutePath(cwd) - ) - ) - MainOps.run(adjusted) - } - case Configured.NotOk(err) => - ScalafixReporter.default.copy(outStream = out).error(err.toString()) - ExitStatus.CommandLineError - } + def run(args: Array[String], cwd: Path, out: PrintStream): ExitStatus = { + MainOps.run(args, Args.default(AbsolutePath(cwd), out)) + } + } diff --git a/scalafix-core/src/main/scala/org/langmeta/internal/ScalametaInternals.scala b/scalafix-core/src/main/scala/org/langmeta/internal/ScalametaInternals.scala index 76ce9dc52..23524289e 100644 --- a/scalafix-core/src/main/scala/org/langmeta/internal/ScalametaInternals.scala +++ b/scalafix-core/src/main/scala/org/langmeta/internal/ScalametaInternals.scala @@ -15,11 +15,21 @@ object ScalametaInternals { case Some(r) => positionFromRange(input, r) case _ => Position.None } + def positionFromRange(input: Input, range: s.Range): Position = { - val start = input.lineToOffset(range.startLine) + range.startCharacter - val end = input.lineToOffset(range.endLine) + range.endCharacter + val inputEnd = Position.Range(input, input.chars.length, input.chars.length) + def lineLength(line: Int): Int = { + val isLastLine = line == inputEnd.startLine + if (isLastLine) inputEnd.endColumn + else input.lineToOffset(line + 1) - input.lineToOffset(line) - 1 + } + val start = input.lineToOffset(range.startLine) + + math.min(range.startCharacter, lineLength(range.startLine)) + val end = input.lineToOffset(range.endLine) + + math.min(range.endCharacter, lineLength(range.endLine)) Position.Range(input, start, end) } + // Workaround for https://github.com/scalameta/scalameta/issues/1115 def formatMessage( pos: Position, diff --git a/scalafix-core/src/main/scala/scalafix/internal/config/LazySemanticdbIndex.scala b/scalafix-core/src/main/scala/scalafix/internal/config/LazySemanticdbIndex.scala deleted file mode 100644 index 4eec839d3..000000000 --- a/scalafix-core/src/main/scala/scalafix/internal/config/LazySemanticdbIndex.scala +++ /dev/null @@ -1,31 +0,0 @@ -package scalafix.internal.config - -import scalafix.v0.SemanticdbIndex -import scala.meta.io.AbsolutePath - -// The challenge when loading a rule is that 1) if it's semantic it needs a -// index constructor argument and 2) we don't know upfront if it's semantic. -// For example, to know if a classloaded rules is semantic or syntactic -// we have to test against it's Class[_]. For default rules, the interface -// to detect if a rule is semantic is different. -// LazySemanticdbIndex allows us to delay the computation of a index right up until -// the moment we instantiate the rule. -//type LazySemanticdbIndex = RuleKind => Option[SemanticdbIndex] -class LazySemanticdbIndex( - f: RuleKind => Option[SemanticdbIndex] = _ => None, - val reporter: ScalafixReporter = ScalafixReporter.default, - // The working directory when compiling file:relativepath/ - val workingDirectory: AbsolutePath = AbsolutePath.workingDirectory, - // Additional classpath entries to use when compiling/classloading rules. - val toolClasspath: List[AbsolutePath] = Nil -) extends Function[RuleKind, Option[SemanticdbIndex]] { - override def apply(v1: RuleKind): Option[SemanticdbIndex] = f(v1) -} - -object LazySemanticdbIndex { - lazy val empty: LazySemanticdbIndex = new LazySemanticdbIndex() - def apply( - f: RuleKind => Option[SemanticdbIndex], - cwd: AbsolutePath = AbsolutePath.workingDirectory): LazySemanticdbIndex = - new LazySemanticdbIndex(f, ScalafixReporter.default, cwd, Nil) -} diff --git a/scalafix-core/src/main/scala/scalafix/internal/config/LintConfig.scala b/scalafix-core/src/main/scala/scalafix/internal/config/LintConfig.scala index 56bc8a528..d706c046a 100644 --- a/scalafix-core/src/main/scala/scalafix/internal/config/LintConfig.scala +++ b/scalafix-core/src/main/scala/scalafix/internal/config/LintConfig.scala @@ -6,7 +6,6 @@ import metaconfig.generic import metaconfig.generic.Surface case class LintConfig( - reporter: ScalafixReporter = ScalafixReporter.default, explain: Boolean = false, ignore: FilterMatcher = FilterMatcher.matchNothing, info: FilterMatcher = FilterMatcher.matchNothing, diff --git a/scalafix-core/src/main/scala/scalafix/internal/config/OutputFormat.scala b/scalafix-core/src/main/scala/scalafix/internal/config/OutputFormat.scala deleted file mode 100644 index d171dad3e..000000000 --- a/scalafix-core/src/main/scala/scalafix/internal/config/OutputFormat.scala +++ /dev/null @@ -1,28 +0,0 @@ -package scalafix.internal.config - -import metaconfig.ConfEncoder -import metaconfig.{Conf, ConfDecoder, Configured} -import scalafix.internal.config.MetaconfigPendingUpstream._ - -sealed abstract class OutputFormat -object OutputFormat { - def apply(arg: String): Either[String, OutputFormat] = { - arg match { - case OutputFormat(format) => - Right(format) - case els => - Left(s"Expected one of ${all.mkString(", ")}, obtained $els") - } - } - def all: Seq[OutputFormat] = List(Default, Sbt) - def unapply(arg: String): Option[OutputFormat] = - all.find(_.toString.equalsIgnoreCase(arg)) - case object Default extends OutputFormat - case object Sbt extends OutputFormat - implicit val encoder: ConfEncoder[OutputFormat] = - ConfEncoder.StringEncoder.contramap[OutputFormat](_.toString.toLowerCase) - implicit val decoder: ConfDecoder[OutputFormat] = - ConfDecoder.instance[OutputFormat] { - case Conf.Str(str) => Configured.fromEither(OutputFormat(str)) - } -} diff --git a/scalafix-core/src/main/scala/scalafix/internal/config/PrintStreamReporter.scala b/scalafix-core/src/main/scala/scalafix/internal/config/PrintStreamReporter.scala index 45690d835..75459f6bc 100644 --- a/scalafix-core/src/main/scala/scalafix/internal/config/PrintStreamReporter.scala +++ b/scalafix-core/src/main/scala/scalafix/internal/config/PrintStreamReporter.scala @@ -1,66 +1,28 @@ package scalafix.internal.config -import java.io.OutputStream -import scala.meta.Position import java.io.PrintStream -import java.util.concurrent.atomic.AtomicInteger -import scalafix.internal.util.Severity -import metaconfig._ -import scala.meta.internal.ScalametaInternals -import MetaconfigPendingUpstream._ +import scala.meta.Position +import scalafix.internal.util.PositionSyntax._ +import scalafix.lint.LintDiagnostic +import scalafix.lint.LintSeverity /** A ScalafixReporter that emits messages to a PrintStream. */ case class PrintStreamReporter( - outStream: PrintStream, - minSeverity: Severity, - filter: FilterMatcher, - includeLoggerName: Boolean, - format: OutputFormat + out: PrintStream ) extends ScalafixReporter { - val reader: ConfDecoder[ScalafixReporter] = - ConfDecoder.instanceF[ScalafixReporter] { c => - ( - c.getField(minSeverity) |@| - c.getField(filter) |@| - c.getField(includeLoggerName) |@| - c.getField(format) - ).map { - case (((a, b), c), d) => - copy( - minSeverity = a, - filter = b, - includeLoggerName = c, - format = d - ) - } - } - private val _errorCount = new AtomicInteger() - override def reset: PrintStreamReporter = copy() - override def reset(os: OutputStream): ScalafixReporter = os match { - case ps: PrintStream => copy(outStream = ps) - case _ => copy(outStream = new PrintStream(os)) + override def lint(d: LintDiagnostic): Unit = { + report(s"[${d.id.fullStringID}] ${d.message}", d.position, d.severity) } - override def withFormat(format: OutputFormat): ScalafixReporter = - copy(format = format) - override def report(message: String, position: Position, severity: Severity)( - implicit ctx: LogContext): Unit = { - if (severity == Severity.Error) { - _errorCount.incrementAndGet() - } - val enclosing = - if (includeLoggerName) s"(${ctx.enclosing.value}) " else "" - val gutter = format match { - case OutputFormat.Default => "" - case OutputFormat.Sbt => s"[$severity] " - } - val formatted = ScalametaInternals.formatMessage( - position, - enclosing + severity.toString, - message) - outStream.println(gutter + formatted) + override private[scalafix] def report( + message: String, + position: Position, + severity: LintSeverity): Unit = { + val formatted = position.formatMessage(severity.toString, message) + out.println(formatted) } +} - /** Returns true if this reporter has seen an error */ - override def errorCount: Int = _errorCount.get() +object PrintStreamReporter { + def default: PrintStreamReporter = PrintStreamReporter(System.out) } diff --git a/scalafix-core/src/main/scala/scalafix/internal/config/ScalafixConfig.scala b/scalafix-core/src/main/scala/scalafix/internal/config/ScalafixConfig.scala index 08e3da228..c5f742ae8 100644 --- a/scalafix-core/src/main/scala/scalafix/internal/config/ScalafixConfig.scala +++ b/scalafix-core/src/main/scala/scalafix/internal/config/ScalafixConfig.scala @@ -1,13 +1,9 @@ package scalafix.internal.config -import java.io.OutputStream -import java.io.PrintStream import scala.meta._ import scala.meta.dialects.Scala212 import metaconfig._ -import metaconfig.Input import metaconfig.generic.Surface -import scalafix.rule.Rule case class ScalafixConfig( parser: ParserConfig = ParserConfig(), @@ -20,35 +16,9 @@ case class ScalafixConfig( lint: LintConfig = LintConfig.default ) { - def withFreshReporters: ScalafixConfig = copy( - reporter = reporter.reset, - lint = lint.copy( - reporter = lint.reporter.reset - ) - ) - - def withFreshReporters(outStream: OutputStream): ScalafixConfig = copy( - reporter = reporter.reset(outStream), - lint = lint.copy( - reporter = lint.reporter.reset(outStream) - ) - ) - val reader: ConfDecoder[ScalafixConfig] = ScalafixConfig.decoder(this) - def transformReporter( - f: ScalafixReporter => ScalafixReporter): ScalafixConfig = - copy( - reporter = f(reporter), - lint = lint.copy(reporter = f(lint.reporter)) - ) - - def withOut(out: PrintStream): ScalafixConfig = - transformReporter(_.reset(out)) - - def withFormat(format: OutputFormat): ScalafixConfig = - transformReporter(_.withFormat(format)) } object ScalafixConfig { @@ -90,20 +60,4 @@ object ScalafixConfig { allowXmlLiterals = true ) - /** Returns config from current working directory, if .scalafix.conf exists. */ - def auto(workingDirectory: AbsolutePath): Option[Input] = { - val file = workingDirectory.resolve(".scalafix.conf") - if (file.isFile && file.toFile.exists()) - Some(Input.File(file.toNIO)) - else None - } - - def fromInput( - input: Input, - index: LazySemanticdbIndex, - extraRules: List[String] = Nil)( - implicit decoder: ConfDecoder[Rule] - ): Configured[(Rule, ScalafixConfig)] = - configFromInput(input, index, extraRules) - } diff --git a/scalafix-core/src/main/scala/scalafix/internal/config/ScalafixMetaconfigReaders.scala b/scalafix-core/src/main/scala/scalafix/internal/config/ScalafixMetaconfigReaders.scala index f4b796f11..577e10f69 100644 --- a/scalafix-core/src/main/scala/scalafix/internal/config/ScalafixMetaconfigReaders.scala +++ b/scalafix-core/src/main/scala/scalafix/internal/config/ScalafixMetaconfigReaders.scala @@ -8,21 +8,17 @@ import scala.reflect.ClassTag import scala.util.Try import scala.util.matching.Regex import scalafix.patch.TreePatch._ -import scalafix.rule.ScalafixRules import java.io.OutputStream import java.io.PrintStream import java.net.URI import java.util.regex.Pattern import java.util.regex.PatternSyntaxException -import scala.collection.immutable.Seq import scala.util.control.NonFatal import metaconfig.Conf import metaconfig.ConfDecoder import metaconfig.ConfError import metaconfig.Configured import metaconfig.Configured.Ok -import metaconfig.typesafeconfig._ -import scalafix.internal.rule.ConfigRule import scalafix.v0._ object ScalafixMetaconfigReaders extends ScalafixMetaconfigReaders @@ -93,81 +89,16 @@ trait ScalafixMetaconfigReaders { ruleDecoder.read(combinedRules).map(rule => rule -> config) } - def defaultRuleDecoder( - getSemanticdbIndex: LazySemanticdbIndex): ConfDecoder[Rule] = - ConfDecoder.instance[Rule] { - case conf @ Conf.Str(value) if !value.contains(":") => - val isSyntactic = ScalafixRules.syntacticNames.contains(value) - val kind = RuleKind(syntactic = isSyntactic) - val index = getSemanticdbIndex(kind) - val names: Map[String, Rule] = - ScalafixRules.syntaxName2rule ++ - index.fold(Map.empty[String, Rule])(ScalafixRules.name2rule) - val result = ReaderUtil.fromMap(names).read(conf) - result match { - case Ok(rule) => - rule.name - .reportDeprecationWarning(value, getSemanticdbIndex.reporter) - case _ => - } - result - } - private lazy val semanticRuleClass = classOf[SemanticRule] - def classloadRule( - index: LazySemanticdbIndex): Class[_] => Seq[SemanticdbIndex] = { cls => - val kind = - if (semanticRuleClass.isAssignableFrom(cls)) RuleKind.Semantic - else RuleKind.Syntactic - index(kind).toList - } - lazy val SlashSeparated: Regex = "([^/]+)/(.*)".r - private def requireSemanticSemanticdbIndex[T]( - index: LazySemanticdbIndex, - what: String)(f: SemanticdbIndex => Configured[T]): Configured[T] = { - index(RuleKind.Semantic).fold( - Configured.error(s"$what requires the semantic API."): Configured[T])(f) - } - def parseReplaceSymbol( from: String, to: String): Configured[(Symbol.Global, Symbol.Global)] = symbolGlobalReader.read(Conf.Str(from)) |@| symbolGlobalReader.read(Conf.Str(to)) - def classloadRuleDecoder(index: LazySemanticdbIndex): ConfDecoder[Rule] = - throw new UnsupportedOperationException - - def baseSyntacticRuleDecoder: ConfDecoder[Rule] = - baseRuleDecoders(LazySemanticdbIndex.empty) - def baseRuleDecoders(index: LazySemanticdbIndex): ConfDecoder[Rule] = { - defaultRuleDecoder(index).orElse(classloadRuleDecoder(index)) - } - def configFromInput( - input: metaconfig.Input, - index: LazySemanticdbIndex, - extraRules: List[String])( - implicit decoder: ConfDecoder[Rule] - ): Configured[(Rule, ScalafixConfig)] = { - metaconfig.Conf.parseInput(input).andThen { conf => - scalafixConfigConfDecoder(decoder, extraRules) - .read(conf) - .andThen { - // Initialize configuration - case (rule, config) => rule.init(conf).map(_ -> config) - } - .andThen { - case (rule, config) => - ConfigRule(config.patches, index).map { configRule => - configRule.fold(rule -> config)(rule.merge(_) -> config) - } - } - } - } - implicit lazy val ReplaceSymbolReader: ConfDecoder[ReplaceSymbol] = ConfDecoder.instanceF[ReplaceSymbol] { c => ( diff --git a/scalafix-core/src/main/scala/scalafix/internal/config/ScalafixReporter.scala b/scalafix-core/src/main/scala/scalafix/internal/config/ScalafixReporter.scala index 160e4a72a..a851bf35b 100644 --- a/scalafix-core/src/main/scala/scalafix/internal/config/ScalafixReporter.scala +++ b/scalafix-core/src/main/scala/scalafix/internal/config/ScalafixReporter.scala @@ -1,64 +1,29 @@ package scalafix.internal.config -import java.io.OutputStream -import scala.meta.Position -import scalafix.internal.util.Severity import metaconfig.ConfDecoder +import metaconfig.ConfEncoder +import scala.meta.Position +import scalafix.lint.LintDiagnostic +import scalafix.lint.LintSeverity trait ScalafixReporter { - - /** Returns true if this reporter has seen an error */ - def hasErrors: Boolean = errorCount > 0 - - /** Clear any internal mutable state */ - private[scalafix] def reset: ScalafixReporter - private[scalafix] def reset(outputStream: OutputStream): ScalafixReporter - private[scalafix] def withFormat(format: OutputFormat): ScalafixReporter - - /** Returns the number of reported errors */ - def errorCount: Int - - /** Messages with severity < minSeverity are skipped. */ - def minSeverity: Severity - - /** Messages whose enclosing scope don't match filter.matches are skipped. */ - def filter: FilterMatcher - - /** Present the message to the user. - * - * In a command-line interface, this might mean "print message to console". - * In an IDE, this might mean putting red/yellow squiggly marks under code. - */ - protected def report(message: String, position: Position, severity: Severity)( - implicit ctx: LogContext): Unit - - private[scalafix] def handleMessage( + private[scalafix] def report( message: String, position: Position, - severity: Severity)(implicit ctx: LogContext): Unit = - if (severity >= minSeverity && filter.matches(ctx.enclosing.value)) { - report(message, position, severity) - } else { - () // do nothing - } - - // format: off - final def trace(message: String, position: Position = Position.None)(implicit ctx: LogContext): Unit = handleMessage(message, position, Severity.Trace) - final def debug(message: String, position: Position = Position.None)(implicit ctx: LogContext): Unit = handleMessage(message, position, Severity.Debug) - final def info (message: String, position: Position = Position.None)(implicit ctx: LogContext): Unit = handleMessage(message, position, Severity.Info) - final def warn (message: String, position: Position = Position.None)(implicit ctx: LogContext): Unit = handleMessage(message, position, Severity.Warn) - final def error(message: String, position: Position = Position.None)(implicit ctx: LogContext): Unit = handleMessage(message, position, Severity.Error) - // format: on + severity: LintSeverity): Unit + private[scalafix] def lint(d: LintDiagnostic): Unit + final def info(message: String, position: Position = Position.None): Unit = + report(message, position, LintSeverity.Info) + final def warn(message: String, position: Position = Position.None): Unit = + report(message, position, LintSeverity.Warning) + final def error(message: String, position: Position = Position.None): Unit = + report(message, position, LintSeverity.Error) } object ScalafixReporter { - val default: PrintStreamReporter = PrintStreamReporter( - Console.out, - Severity.Info, - FilterMatcher.matchEverything, - includeLoggerName = false, - format = OutputFormat.Default - ) - implicit val scalafixReporterReader: ConfDecoder[ScalafixReporter] = - default.reader + def default: ScalafixReporter = PrintStreamReporter.default + implicit val decoder: ConfDecoder[ScalafixReporter] = + ConfDecoder.stringConfDecoder.map(_ => default) + implicit val encoder: ConfEncoder[ScalafixReporter] = + ConfEncoder.StringEncoder.contramap(_ => "") } diff --git a/scalafix-core/src/main/scala/scalafix/internal/diff/DiffUtils.scala b/scalafix-core/src/main/scala/scalafix/internal/diff/DiffUtils.scala index 02a4587e2..7a71c22a8 100644 --- a/scalafix-core/src/main/scala/scalafix/internal/diff/DiffUtils.scala +++ b/scalafix-core/src/main/scala/scalafix/internal/diff/DiffUtils.scala @@ -10,11 +10,11 @@ object DiffUtils { originalLines: List[String], revisedLines: List[String], contextSize: Int): String = { - val patch = DU.diff(originalLines.toSeq.asJava, revisedLines.toSeq.asJava) + val patch = DU.diff(originalLines.asJava, revisedLines.asJava) val diff = DU.generateUnifiedDiff( originalFileName, revisedFileName, - originalLines.toSeq.asJava, + originalLines.asJava, patch, contextSize) diff.asScala.mkString("\n") diff --git a/scalafix-core/src/main/scala/scalafix/internal/patch/DeprecatedRuleCtx.scala b/scalafix-core/src/main/scala/scalafix/internal/patch/DeprecatedRuleCtx.scala index c45729a72..d09a4980f 100644 --- a/scalafix-core/src/main/scala/scalafix/internal/patch/DeprecatedRuleCtx.scala +++ b/scalafix-core/src/main/scala/scalafix/internal/patch/DeprecatedRuleCtx.scala @@ -5,10 +5,7 @@ import scala.meta.Input import scala.meta.Tree import scala.meta.contrib.AssociatedComments import scala.meta.tokens.Tokens -import scalafix.v0.LintMessage -import scalafix.v0.Patch import scalafix.v0.RuleCtx -import scalafix.rule.RuleName import scalafix.util.MatchingParens import scalafix.util.SemanticdbIndex import scalafix.util.TokenList @@ -29,18 +26,6 @@ class DeprecatedRuleCtx(doc: Doc) extends RuleCtx with DeprecatedPatchOps { throw new UnsupportedOperationException override private[scalafix] def toks(t: Tree) = t.tokens(doc.config.dialect) override private[scalafix] def config = doc.config - override private[scalafix] def printLintMessage(msg: LintMessage): Unit = { - // Copy-paste from RuleCtxImpl :( - val category = msg.category.withConfig(config.lint) - config.lint.reporter.handleMessage( - msg.format(config.lint.explain), - msg.position, - category.severity.toSeverity - ) - } - override private[scalafix] def filter( - patchesByName: Map[RuleName, Patch], - index: SemanticdbIndex) = { - doc.escapeHatch.filter(patchesByName, this, index, doc.diffDisable) - } + override private[scalafix] def escapeHatch = doc.escapeHatch + override private[scalafix] def diffDisable = doc.diffDisable } diff --git a/scalafix-core/src/main/scala/scalafix/internal/patch/EscapeHatch.scala b/scalafix-core/src/main/scala/scalafix/internal/patch/EscapeHatch.scala index 686ec6557..f995e52b1 100644 --- a/scalafix-core/src/main/scala/scalafix/internal/patch/EscapeHatch.scala +++ b/scalafix-core/src/main/scala/scalafix/internal/patch/EscapeHatch.scala @@ -10,15 +10,17 @@ import scala.meta.tokens.Token import scala.meta.tokens.Token.Comment import scalafix.v0._ import scalafix.internal.config.FilterMatcher +import scalafix.internal.config.ScalafixConfig import scalafix.internal.diff.DiffDisable import scalafix.internal.patch.EscapeHatch._ -import scalafix.lint.LintMessage +import scalafix.lint.LintDiagnostic import scalafix.patch.AtomicPatch import scalafix.patch.Concat import scalafix.patch.EmptyPatch import scalafix.patch.LintPatch import scalafix.rule.RuleName import scalafix.util.TreeExtractors.Mods +import scalafix.internal.util.LintSyntax._ /** EscapeHatch is an algorithm to selectively disable rules. There * are two mechanisms to do so: anchored comments and the @@ -34,9 +36,11 @@ class EscapeHatch private ( patchesByName: Map[RuleName, Patch], ctx: RuleCtx, index: SemanticdbIndex, - diff: DiffDisable): (Patch, List[LintMessage]) = { + diff: DiffDisable, + config: ScalafixConfig + ): (Patch, List[LintDiagnostic]) = { val usedEscapes = mutable.Set.empty[EscapeFilter] - val lintMessages = List.newBuilder[LintMessage] + val lintMessages = List.newBuilder[LintDiagnostic] def isDisabledByEscape(name: RuleName, start: Int): Boolean = // annotatedEscapes takes precedence over anchoredEscapes @@ -67,16 +71,15 @@ class EscapeHatch private ( case Concat(a, b) => Concat(loop(name, a), loop(name, b)) - case LintPatch(orphanLint) => - val lint = orphanLint.withOwner(name) - + case LintPatch(lint) => val byGit = diff.isDisabled(lint.position) - val byEscape = isDisabledByEscape(lint.id, lint.position.start) + val id = lint.fullStringID(name) + val byEscape = isDisabledByEscape(id, lint.position.start) val isLintDisabled = byGit || byEscape if (!isLintDisabled) { - lintMessages += lint + lintMessages += lint.toDiagnostic(name, config) } EmptyPatch @@ -84,12 +87,14 @@ class EscapeHatch private ( case e => e } - val patches = - patchesByName.map { case (name, patch) => loop(name, patch) }.asPatch + val patches = patchesByName.map { + case (name, patch) => loop(name, patch) + }.asPatch val unusedWarnings = (annotatedEscapes.unusedEscapes(usedEscapes) ++ - anchoredEscapes.unusedEscapes(usedEscapes)) - .map(UnusedWarning.at) + anchoredEscapes.unusedEscapes(usedEscapes)).map { pos => + UnusedScalafixSuppression.at(pos).toDiagnostic(UnusedName, config) + } val warnings = lintMessages.result() ++ unusedWarnings (patches, warnings) } @@ -97,9 +102,9 @@ class EscapeHatch private ( object EscapeHatch { - private val UnusedWarning = LintCategory - .warning("", "Unused Scalafix suppression. This can be removed") - .withOwner(RuleName("UnusedScalafixSuppression")) + private val UnusedScalafixSuppression = + LintCategory.warning("", "Unused Scalafix suppression. This can be removed") + private val UnusedName = RuleName("UnusedScalafixSuppression") private type EscapeTree = TreeMap[EscapeOffset, List[EscapeFilter]] diff --git a/scalafix-core/src/main/scala/scalafix/internal/rule/ConfigRule.scala b/scalafix-core/src/main/scala/scalafix/internal/rule/ConfigRule.scala deleted file mode 100644 index 96114a872..000000000 --- a/scalafix-core/src/main/scala/scalafix/internal/rule/ConfigRule.scala +++ /dev/null @@ -1,32 +0,0 @@ -package scalafix.internal.rule - -import scalafix.v0._ -import scalafix.internal.config._ -import scalafix.rule.Rule -import metaconfig.ConfError -import metaconfig.Configured - -object ConfigRule { - def apply( - patches: ConfigRulePatches, - getSemanticdbIndex: LazySemanticdbIndex): Configured[Option[Rule]] = { - val configurationPatches = patches.all - if (configurationPatches.isEmpty) Configured.Ok(None) - else { - getSemanticdbIndex(RuleKind.Semantic) match { - case None => - ConfError - .message(".scalafix.conf patches require the Semantic API.") - .notOk - case Some(index) => - val rule = Rule.constant( - ".scalafix.conf", - configurationPatches.asPatch, - index - ) - Configured.Ok(Some(rule)) - } - } - } - -} diff --git a/scalafix-core/src/main/scala/scalafix/internal/rule/ExplicitResultTypes.scala b/scalafix-core/src/main/scala/scalafix/internal/rule/ExplicitResultTypes.scala index 07010569b..6733fbb94 100644 --- a/scalafix-core/src/main/scala/scalafix/internal/rule/ExplicitResultTypes.scala +++ b/scalafix-core/src/main/scala/scalafix/internal/rule/ExplicitResultTypes.scala @@ -82,7 +82,10 @@ case class ExplicitResultTypes( pos: Position, symbol: Symbol ): PrettyResult[Type] = { - val info = index.asInstanceOf[SymbolTable].info(symbol.syntax).get + val info = index + .asInstanceOf[SymbolTable] + .info(symbol.syntax) + .getOrElse(throw new NoSuchElementException(symbol.syntax)) val tpe = info.signature match { case method: s.MethodSignature => method.returnType diff --git a/scalafix-core/src/main/scala/scalafix/internal/rule/RuleCtxImpl.scala b/scalafix-core/src/main/scala/scalafix/internal/rule/RuleCtxImpl.scala index a7f90b871..6fae2381c 100644 --- a/scalafix-core/src/main/scala/scalafix/internal/rule/RuleCtxImpl.scala +++ b/scalafix-core/src/main/scala/scalafix/internal/rule/RuleCtxImpl.scala @@ -11,7 +11,6 @@ import scalafix.internal.config.ScalafixConfig import scalafix.internal.diff.DiffDisable import scalafix.internal.patch.DeprecatedPatchOps import scalafix.internal.patch.EscapeHatch -import scalafix.rule.RuleName import scalafix.util.MatchingParens import scalafix.util.SemanticdbIndex import scalafix.util.TokenList @@ -49,19 +48,4 @@ case class RuleCtxImpl( logger.elem(values: _*) } - def printLintMessage(msg: LintMessage): Unit = { - val category = msg.category.withConfig(config.lint) - - config.lint.reporter.handleMessage( - msg.format(config.lint.explain), - msg.position, - category.severity.toSeverity - ) - } - - def filter( - patchesByName: Map[RuleName, Patch], - index: SemanticdbIndex): (Patch, List[LintMessage]) = - escapeHatch.filter(patchesByName, this, index, diffDisable) - } diff --git a/scalafix-core/src/main/scala/scalafix/internal/util/LintSyntax.scala b/scalafix-core/src/main/scala/scalafix/internal/util/LintSyntax.scala new file mode 100644 index 000000000..c97919758 --- /dev/null +++ b/scalafix-core/src/main/scala/scalafix/internal/util/LintSyntax.scala @@ -0,0 +1,51 @@ +package scalafix.internal.util + +import scalafix.internal.config.ScalafixConfig +import scalafix.lint.LintDiagnostic +import scalafix.lint.LintID +import scalafix.lint.LintMessage +import scalafix.rule.RuleName +import scala.meta.inputs.Position +import scalafix.lint.LintSeverity + +object LintSyntax { + + implicit class XtensionLintMessage(msg: LintMessage) { + def fullStringID(name: RuleName): String = + LintID(name.value, msg.categoryID).fullStringID + + def toDiagnostic( + ruleName: RuleName, + config: ScalafixConfig): LintDiagnostic = { + val id = LintID(ruleName.value, msg.categoryID) + LintDiagnostic( + msg, + ruleName, + config.lint.getConfiguredSeverity(id.fullStringID) + ) + } + } + + implicit class XtensionLintDiagnostic(msg: LintDiagnostic) { + def withMessage(newMessage: String): LintDiagnostic = { + new EagerLintDiagnostic( + newMessage, + msg.position, + msg.severity, + msg.explanation, + msg.id + ) + } + + } + final class EagerLintDiagnostic( + val message: String, + val position: Position, + val severity: LintSeverity, + val explanation: String, + val id: LintID + ) extends LintDiagnostic { + override def toString: String = formattedMessage + } + +} diff --git a/scalafix-core/src/main/scala/scalafix/internal/util/PositionSyntax.scala b/scalafix-core/src/main/scala/scalafix/internal/util/PositionSyntax.scala new file mode 100644 index 000000000..0583f6c88 --- /dev/null +++ b/scalafix-core/src/main/scala/scalafix/internal/util/PositionSyntax.scala @@ -0,0 +1,65 @@ +package scalafix.internal.util + +import scala.meta.internal.{semanticdb => s} +import scala.meta._ +import scala.meta.internal.ScalametaInternals + +object PositionSyntax { + + implicit class XtensionPositionsScalafix(private val pos: Position) + extends AnyVal { + + /** Returns a formatted string of this position including filename/line/caret. */ + def formatMessage(severity: String, message: String): String = pos match { + case Position.None => + s"$severity: $message" + case _ => + new java.lang.StringBuilder() + .append(lineInput) + .append(if (severity.isEmpty) "" else " ") + .append(severity) + .append( + if (message.isEmpty) "" + else if (severity.isEmpty) " " + else if (message.startsWith("\n")) ":" + else ": " + ) + .append(message) + .append("\n") + .append(lineContent) + .append("\n") + .append(lineCaret) + .toString + } + + def lineInput: String = + s"${pos.input.syntax}:${pos.startLine + 1}:${pos.startColumn + 1}:" + + def lineCaret: String = pos match { + case Position.None => + "" + case _ => + val caret = + if (pos.start == pos.end) "^" + else if (pos.startLine == pos.endLine) "^" * (pos.end - pos.start) + else "^" + (" " * pos.startColumn) + caret + } + + def lineContent: String = pos match { + case Position.None => "" + case range: Position.Range => + val pos = ScalametaInternals.positionFromRange( + range.input, + s.Range( + startLine = range.startLine, + startCharacter = 0, + endLine = range.startLine, + endCharacter = Int.MaxValue + ) + ) + pos.text + } + } + +} diff --git a/scalafix-core/src/main/scala/scalafix/internal/v1/Rules.scala b/scalafix-core/src/main/scala/scalafix/internal/v1/Rules.scala index 049ac2085..f8e095503 100644 --- a/scalafix-core/src/main/scala/scalafix/internal/v1/Rules.scala +++ b/scalafix-core/src/main/scala/scalafix/internal/v1/Rules.scala @@ -8,6 +8,7 @@ import scalafix.internal.config.MetaconfigPendingUpstream import scalafix.internal.config.NoInferConfig import scalafix.internal.rule._ import scalafix.internal.util.SuppressOps +import scalafix.lint.LintDiagnostic import scalafix.lint.LintMessage import scalafix.patch.Patch import scalafix.rule.RuleName @@ -49,7 +50,7 @@ case class Rules(rules: List[Rule] = Nil) { def semanticPatch( doc: SemanticDoc, - suppress: Boolean): (String, List[LintMessage]) = { + suppress: Boolean): (String, List[LintDiagnostic]) = { val fixes = rules.iterator.map { case rule: SemanticRule => rule.name -> rule.fix(doc) @@ -65,7 +66,7 @@ case class Rules(rules: List[Rule] = Nil) { def syntacticPatch( doc: Doc, - suppress: Boolean): (String, List[LintMessage]) = { + suppress: Boolean): (String, List[LintDiagnostic]) = { require(!isSemantic, semanticRules.map(_.name).mkString("+")) val fixes = syntacticRules.iterator.map { rule => rule.name -> rule.fix(doc) diff --git a/scalafix-core/src/main/scala/scalafix/lint/LintCategory.scala b/scalafix-core/src/main/scala/scalafix/lint/LintCategory.scala index bfc8fd820..9a324e7c6 100644 --- a/scalafix-core/src/main/scala/scalafix/lint/LintCategory.scala +++ b/scalafix-core/src/main/scala/scalafix/lint/LintCategory.scala @@ -29,7 +29,7 @@ final case class LintCategory( LintMessage(explanation, position, noExplanation) def withOwner(owner: RuleName): LintCategory = - copy(id = deprecatedKey(owner)) + copy(id = fullId(owner)) def withConfig(config: LintConfig): LintCategory = { val newSeverity = @@ -40,7 +40,7 @@ final case class LintCategory( copy(severity = newSeverity) } - private def deprecatedKey(owner: RuleName): String = + def fullId(owner: RuleName): String = if (owner.isEmpty) id else if (id.isEmpty) owner.value else s"${owner.value}.$id" diff --git a/scalafix-core/src/main/scala/scalafix/lint/LintDiagnostic.scala b/scalafix-core/src/main/scala/scalafix/lint/LintDiagnostic.scala new file mode 100644 index 000000000..d92fe0016 --- /dev/null +++ b/scalafix-core/src/main/scala/scalafix/lint/LintDiagnostic.scala @@ -0,0 +1,66 @@ +package scalafix.lint + +import scala.meta.Position +import scalafix.internal.util.PositionSyntax._ +import scalafix.rule.RuleName + +/** + * A linter messages that has been associated with a rule. + * + * @note The difference between LintDiagnostic and LintMessage is that + * LintMessage is not associated with a rule while LintDiagnostic + * interfaces matches closely the ScalafixDiagnostic interface + * from the scalafix-interfaces Java-only module. + */ +trait LintDiagnostic { + + /** The main message of this diagnostic. */ + def message: String + + /** The source code location where this violation appears, Position.None if not available. */ + def position: Position + + /** The severity of this message: error, warning or info. */ + def severity: LintSeverity + + /** An optional detailed explanation of this message. */ + def explanation: String + + /** A unique identifier for the category of this lint diagnostic. */ + def id: LintID + + /** A pretty-printed representation of this diagnostic without detailed explanation. */ + def formattedMessage: String = { + val msg = new StringBuilder() + .append("[") + .append(id.fullStringID) + .append("]:") + .append(if (message.isEmpty || message.startsWith("\n")) "" else " ") + .append(message) + .toString() + position.formatMessage(severity.toString, msg) + } +} + +object LintDiagnostic { + def apply( + message: LintMessage, + rule: RuleName, + configuredSeverity: Option[LintSeverity]): LintDiagnostic = + new LazyLintDiagnostic(message, rule, configuredSeverity) + + final class LazyLintDiagnostic( + val lintMessage: LintMessage, + val rule: RuleName, + val configuredSeverity: Option[LintSeverity] + ) extends LintDiagnostic { + override def toString: String = formattedMessage + override def message: String = lintMessage.message + override def position: Position = lintMessage.position + override def severity: LintSeverity = + configuredSeverity.getOrElse(lintMessage.severity) + override def explanation: String = lintMessage.explanation + override def id: LintID = LintID(rule.value, lintMessage.categoryID) + } + +} diff --git a/scalafix-core/src/main/scala/scalafix/lint/LintID.scala b/scalafix-core/src/main/scala/scalafix/lint/LintID.scala new file mode 100644 index 000000000..673d1c627 --- /dev/null +++ b/scalafix-core/src/main/scala/scalafix/lint/LintID.scala @@ -0,0 +1,24 @@ +package scalafix.lint + +/** A unique identifier for this category of lint diagnostics + * + * The contract of id is that all diagnostics of the same "category" will have the same id. + * For example, the DisableSyntax rule has a unique ID for each category such as "noSemicolon" + * or "noTabs". + * + * @param rule the name of the rule that produced this diagnostic. + * @param categoryID the sub-category within this rule, if any. + * Empty if the rule only reports diagnostics of a single + * category. + */ +final case class LintID(rule: String, categoryID: String) { + def fullStringID: String = + if (categoryID.isEmpty && rule.isEmpty) "" + else if (categoryID.isEmpty) rule + else if (rule.isEmpty) categoryID + else s"$rule.$categoryID" +} + +object LintID { + val empty = LintID("", "") +} diff --git a/scalafix-core/src/main/scala/scalafix/lint/LintMessage.scala b/scalafix-core/src/main/scala/scalafix/lint/LintMessage.scala index 61bbf0bbc..ab81df85b 100644 --- a/scalafix-core/src/main/scala/scalafix/lint/LintMessage.scala +++ b/scalafix-core/src/main/scala/scalafix/lint/LintMessage.scala @@ -3,34 +3,88 @@ package scalafix.lint import scala.meta.Position import scalafix.rule.RuleName -/** An observation of a LintCategory at a particular position +/** A linter message reporting a code style violation. * - * @param message The message to display to the user. If empty, LintID.explanation - * is used instead. - * @param position Optionally place a caret under a location in a source file. - * For an empty position use Position.None. - * @param category the LintCategory associated with this message. + * It's idiomatic to implement a custom class that extends this trait for each + * unique category of linting messages. For example, if you have an "unused code" + * linter then you might want to create a class UnusedCode extends LintMessage + * class with the appropriate context. + * + * Expensive values such as the message and explanation can be computed on-demand. + * + * @note for a LintMessage that is associated with a specific rule use + * [[scalafix.lint.LintDiagnostic]]. */ -final case class LintMessage( - message: String, - position: Position, - category: LintCategory -) { - - def format(explain: Boolean): String = { - val explanation = - if (explain) - s""" - |Explanation: - |${category.explanation} - |""".stripMargin - else "" - - s"[${category.id}] $message$explanation" +trait LintMessage { + + /** The main message of this diagnostic. */ + def message: String + + /** The source code location where this violation appears, Position.None if not available */ + def position: Position + + /** The severity of this message: error, warning or info */ + def severity: LintSeverity = LintSeverity.Error + + /** String ID for the category of this lint message. + * + * A linter diagnostic is keyed by two unique values: + * - the rule name (which is not available in a LintMessage + * - the category ID (this value) + * + * The categoryID may be empty, in which case the category of this message will be uniquely + * defined by the rule name. If a linter rule reports multiple different kinds of diagnostics + * then it's recommended to provide non-empty categoryID. + */ + def categoryID: String = "" + + /** An optional detailed explanation of this message. */ + def explanation: String = "" + +} + +object LintMessage { + + /** Construct an eager instance of a LintMessage. */ + def apply( + message: String, + position: Position, + category: LintCategory): EagerLintMessage = { + EagerLintMessage(message, position, category) } - def id: String = category.id + /** An observation of a LintCategory at a particular position. + * + * @param message The message to display to the user. If empty, LintID.explanation + * is used instead. + * @param position Optionally place a caret under a location in a source file. + * For an empty position use Position.None. + * @param category the LintCategory associated with this message. + */ + final case class EagerLintMessage( + message: String, + position: Position, + category: LintCategory + ) extends LintMessage { + def format(explain: Boolean): String = { + val explanation = + if (explain) + s""" + |Explanation: + |${category.explanation} + |""".stripMargin + else "" + + s"[${category.id}] $message$explanation" + } - def withOwner(owner: RuleName): LintMessage = - copy(category = category.withOwner(owner)) + def id: String = category.id + + def withOwner(owner: RuleName): EagerLintMessage = + copy(category = category.withOwner(owner)) + + override def severity: LintSeverity = category.severity + override def categoryID: String = category.id + override def explanation: String = category.explanation + } } diff --git a/scalafix-core/src/main/scala/scalafix/patch/Patch.scala b/scalafix-core/src/main/scala/scalafix/patch/Patch.scala index 7e3ee4e3e..c46abcb3f 100644 --- a/scalafix-core/src/main/scala/scalafix/patch/Patch.scala +++ b/scalafix-core/src/main/scala/scalafix/patch/Patch.scala @@ -19,6 +19,7 @@ import scala.meta.tokens.Tokens import scalafix.internal.config.ScalafixMetaconfigReaders import scalafix.internal.util.SuppressOps import scalafix.internal.util.SymbolOps.Root +import scalafix.lint.LintDiagnostic import scalafix.patch.TreePatch.AddGlobalImport import scalafix.patch.TreePatch.RemoveGlobalImport import scalafix.rule.RuleCtx @@ -194,9 +195,15 @@ object Patch { ctx: RuleCtx, index: Option[SemanticdbIndex], suppress: Boolean = false - ): (String, List[LintMessage]) = { + ): (String, List[LintDiagnostic]) = { val idx = index.getOrElse(SemanticdbIndex.empty) - val (patch, lints) = ctx.filter(patchesByName, idx) + val (patch, lints) = ctx.escapeHatch.filter( + patchesByName, + ctx, + idx, + ctx.diffDisable, + ctx.config + ) val finalPatch = if (suppress) { patch + SuppressOps.addComments(ctx.tokens, lints.map(_.position)) @@ -210,14 +217,14 @@ object Patch { private[scalafix] def syntactic( patchesByName: Map[scalafix.rule.RuleName, scalafix.Patch], doc: Doc - ): (String, List[LintMessage]) = { + ): (String, List[LintDiagnostic]) = { apply(patchesByName, doc.toRuleCtx, None, suppress = false) } private[scalafix] def semantic( patchesByName: Map[scalafix.rule.RuleName, scalafix.Patch], doc: SemanticDoc - ): (String, List[LintMessage]) = { + ): (String, List[LintDiagnostic]) = { apply( patchesByName, doc.doc.toRuleCtx, diff --git a/scalafix-core/src/main/scala/scalafix/rule/RuleName.scala b/scalafix-core/src/main/scala/scalafix/rule/RuleName.scala index 44c0f4d7d..eaf8fa487 100644 --- a/scalafix-core/src/main/scala/scalafix/rule/RuleName.scala +++ b/scalafix-core/src/main/scala/scalafix/rule/RuleName.scala @@ -1,7 +1,6 @@ package scalafix.rule import scala.language.implicitConversions - import scalafix.internal.config.ScalafixReporter import scalafix.util.Deprecated diff --git a/scalafix-core/src/main/scala/scalafix/v0/Rule.scala b/scalafix-core/src/main/scala/scalafix/v0/Rule.scala index 13c9d12e9..fb29130ad 100644 --- a/scalafix-core/src/main/scala/scalafix/v0/Rule.scala +++ b/scalafix-core/src/main/scala/scalafix/v0/Rule.scala @@ -6,6 +6,7 @@ import scalafix.internal.config.ScalafixConfig import scalafix.syntax._ import metaconfig.Conf import metaconfig.Configured +import scalafix.lint.LintDiagnostic /** A Scalafix Rule. * @@ -78,11 +79,11 @@ abstract class Rule(ruleName: RuleName) { self => final def apply(ctx: RuleCtx, patches: Map[RuleName, Patch]): String = { // This overload of apply if purely for convenience // Use `applyAndLint` to iterate over LintMessage without printing to the console - val (fixed, lintMessages) = Patch(patches, ctx, semanticOption) - lintMessages.foreach(ctx.printLintMessage) + val (fixed, diagnostics) = Patch(patches, ctx, semanticOption) + diagnostics.foreach(diag => ctx.config.reporter.lint(diag)) fixed } - final def applyAndLint(ctx: RuleCtx): (String, List[LintMessage]) = + final def applyAndLint(ctx: RuleCtx): (String, List[LintDiagnostic]) = Patch(fixWithName(ctx), ctx, semanticOption) /** Returns unified diff from applying this patch */ diff --git a/scalafix-core/src/main/scala/scalafix/v0/RuleCtx.scala b/scalafix-core/src/main/scala/scalafix/v0/RuleCtx.scala index 8f493f07a..cdf43de9f 100644 --- a/scalafix-core/src/main/scala/scalafix/v0/RuleCtx.scala +++ b/scalafix-core/src/main/scala/scalafix/v0/RuleCtx.scala @@ -10,6 +10,7 @@ import scalafix.patch.PatchOps import scalafix.util.MatchingParens import scalafix.util.TokenList import org.scalameta.FileLine +import scalafix.internal.patch.EscapeHatch trait RuleCtx extends PatchOps { @@ -48,11 +49,8 @@ trait RuleCtx extends PatchOps { // Private scalafix methods, subject for removal without notice. private[scalafix] def toks(t: Tree): Tokens private[scalafix] def config: ScalafixConfig - private[scalafix] def printLintMessage(msg: LintMessage): Unit - - private[scalafix] def filter( - patchesByName: Map[RuleName, Patch], - index: SemanticdbIndex): (Patch, List[LintMessage]) + private[scalafix] def escapeHatch: EscapeHatch + private[scalafix] def diffDisable: DiffDisable } object RuleCtx { diff --git a/scalafix-interfaces/src/main/java/scalafix/interfaces/Scalafix.java b/scalafix-interfaces/src/main/java/scalafix/interfaces/Scalafix.java new file mode 100644 index 000000000..c1cc553c1 --- /dev/null +++ b/scalafix-interfaces/src/main/java/scalafix/interfaces/Scalafix.java @@ -0,0 +1,85 @@ +package scalafix.interfaces; + + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; + +/** + * Public API for reflectively invoking Scalafix from a build tool or IDE integration. + * + * To obtain an instance of Scalafix, use {@link Scalafix#classloadInstance(ClassLoader)}. + * + * @implNote This interface is not intended to be extended, the only implementation of this interface + * should live in the Scalafix repository. + */ +public interface Scalafix { + + /** + * Run the Scalafix commmand-line interface main function. + * + * @param args The arguments passed to the command-line interface. + */ + ScalafixError[] runMain(ScalafixMainArgs args); + + /** + * @return Construct a new instance of {@link ScalafixMainArgs} that can be later passed to {@link #runMain(ScalafixMainArgs) }. + */ + ScalafixMainArgs newMainArgs(); + + /** + * Get --help message for running the Scalafix command-line interface. + * + * @param screenWidth The width of the screen, used for wrapping long sentences + * into multiple lines. + * @return The help message as a string. + */ + String mainHelp(int screenWidth); + + /** + * @return The release version of the current Scalafix API instance. + */ + String scalafixVersion(); + + /** + * @return The recommended Scalameta version to match the current Scalafix API instance. + */ + String scalametaVersion(); + + /** + * @return The exact Scala versions that are supported by the recommended {@link #scalametaVersion()} + */ + String[] supportedScalaVersions(); + + /** + * @return The most recent Scala 2.11 version in {@link #supportedScalaVersions()} + */ + String scala211(); + + /** + * @return The most recent Scala 2.12 version in {@link #supportedScalaVersions()} + */ + String scala212(); + + + /** + * JVM runtime reflection method helper to classload an instance of {@link Scalafix}. + * + * @param classLoader Classloader containing the full Scalafix classpath, including the scalafix-cli module. + * @return An implementation of the {@link Scalafix} interface. + * @throws ScalafixException in case of errors during classloading, most likely caused + * by an incorrect classloader argument. + */ + static Scalafix classloadInstance(ClassLoader classLoader) throws ScalafixException { + try { + Class cls = classLoader.loadClass("scalafix.internal.interfaces.ScalafixImpl"); + Constructor ctor = cls.getDeclaredConstructor(); + ctor.setAccessible(true); + return (Scalafix) ctor.newInstance(); + } catch (ClassNotFoundException | NoSuchMethodException | + IllegalAccessException | InvocationTargetException | + InstantiationException ex) { + throw new ScalafixException( + "Failed to reflectively load Scalafix with classloader " + classLoader.toString(), ex); + } + } +} diff --git a/scalafix-interfaces/src/main/java/scalafix/interfaces/ScalafixDiagnostic.java b/scalafix-interfaces/src/main/java/scalafix/interfaces/ScalafixDiagnostic.java new file mode 100644 index 000000000..ed90a5c85 --- /dev/null +++ b/scalafix-interfaces/src/main/java/scalafix/interfaces/ScalafixDiagnostic.java @@ -0,0 +1,36 @@ +package scalafix.interfaces; + +import java.util.Optional; + +/** + * A diagnostic such as a linter message or general error reported by Scalafix. + */ +public interface ScalafixDiagnostic { + + + /** + * @return The short message of this diagnostic. + */ + String message(); + + /** + * @return An optional detailed explanation for this diagnostic. May be empty. + */ + String explanation(); + + /** + * @return The severity of this message: error, warning or info. + */ + ScalafixSeverity severity(); + + /** + * @return The source position where this diagnostic points to, if any. + */ + Optional position(); + + /** + * @return The unique identifier for the category of this linter message, if any. + */ + Optional lintID(); + +} diff --git a/scalafix-interfaces/src/main/java/scalafix/interfaces/ScalafixError.java b/scalafix-interfaces/src/main/java/scalafix/interfaces/ScalafixError.java new file mode 100644 index 000000000..a87122658 --- /dev/null +++ b/scalafix-interfaces/src/main/java/scalafix/interfaces/ScalafixError.java @@ -0,0 +1,51 @@ +package scalafix.interfaces; + +/** + * A code representing a category of errors that happen while running Scalafix. + */ +public enum ScalafixError { + /** + * Something unexpected happened. + */ + UnexpectedError, + /** + * A source file failed to parse. + */ + ParseError, + /** + * A command-line argument parsed incorrectly. + */ + CommandLineError, + /** + * A semantic rewrite was run on a source file that has no associated META-INF/semanticdb/.../*.semanticdb. + * + * Typical causes of this error include + * + *
    + *
  • Incorrect --classpath, make sure the classpath is compiled with the SemanticDB compiler plugin
  • + *
  • Incorrect --sourceroot, if the classpath is compiled with a custom -P:semanticdb:sourceroot:{path} + * then make sure the provided --sourceroot is correct. + *
  • + *
+ */ + MissingSemanticdbError, + /** + * The source file contents on disk have changed since the last compilation with the SemanticDB compiler plugin. + * + * To resolve this error re-compile the project and re-run Scalafix. + */ + StaleSemanticdbError, + /** + * When run with {@link ScalafixMainMode#TEST}, this error is returned when a file on disk does not match + * the file contents if it was fixed with Scalafix. + */ + TestError, + /** + * A linter error was reported. + */ + LinterError, + /** + * No files were provided to Scalafix so nothing happened. + */ + NoFilesError +} diff --git a/scalafix-interfaces/src/main/java/scalafix/interfaces/ScalafixException.java b/scalafix-interfaces/src/main/java/scalafix/interfaces/ScalafixException.java new file mode 100644 index 000000000..c3d018151 --- /dev/null +++ b/scalafix-interfaces/src/main/java/scalafix/interfaces/ScalafixException.java @@ -0,0 +1,10 @@ +package scalafix.interfaces; + +/** + * An error occurred while classloading an instance of {@link Scalafix}. + */ +public class ScalafixException extends Exception { + ScalafixException(String message, Exception cause) { + super(message, cause); + } +} diff --git a/scalafix-interfaces/src/main/java/scalafix/interfaces/ScalafixInput.java b/scalafix-interfaces/src/main/java/scalafix/interfaces/ScalafixInput.java new file mode 100644 index 000000000..770541b5b --- /dev/null +++ b/scalafix-interfaces/src/main/java/scalafix/interfaces/ScalafixInput.java @@ -0,0 +1,26 @@ +package scalafix.interfaces; + +import java.nio.file.Path; +import java.util.Optional; + +/** + * An input represents an input source for code such as a file or virtual file. + */ +public interface ScalafixInput { + + /** + * @return The full text contents of this input. + */ + CharSequence text(); + + /** + * @return The string filename of this input. + */ + String filename(); + + /** + * @return A path to the original source of this input if it came from a file on disk + * or a virtual file system. + */ + Optional path(); +} diff --git a/scalafix-interfaces/src/main/java/scalafix/interfaces/ScalafixLintID.java b/scalafix-interfaces/src/main/java/scalafix/interfaces/ScalafixLintID.java new file mode 100644 index 000000000..4217b9277 --- /dev/null +++ b/scalafix-interfaces/src/main/java/scalafix/interfaces/ScalafixLintID.java @@ -0,0 +1,25 @@ +package scalafix.interfaces; + +/** + * A unique identifier for this category of lint diagnostics + *

+ * The contract of id is that all diagnostics of the same "category" will have the same id. + * For example, the DisableSyntax rule has a unique ID for each category such as "noSemicolon" + * or "noTabs". + */ +public interface ScalafixLintID { + + /** + * @return The name of the rule that produced this diagnostic. For example, "Disable". + */ + String ruleName(); + + /** + * @return the sub-category within this rule, if any. + * Empty if the rule only reports diagnostics of a single + * category. For example, "get" when the full lint ID + * is "Disable.get". + */ + String categoryID(); + +} diff --git a/scalafix-interfaces/src/main/java/scalafix/interfaces/ScalafixMainArgs.java b/scalafix-interfaces/src/main/java/scalafix/interfaces/ScalafixMainArgs.java new file mode 100644 index 000000000..2e11077f7 --- /dev/null +++ b/scalafix-interfaces/src/main/java/scalafix/interfaces/ScalafixMainArgs.java @@ -0,0 +1,109 @@ +package scalafix.interfaces; + +import java.io.PrintStream; +import java.net.URLClassLoader; +import java.nio.charset.Charset; +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.util.List; + +/** + * Wrapper around arguments for invoking the Scalafix command-line interface main method. + *

+ * To obtain an instance of MainArgs, use {@link scalafix.interfaces.Scalafix#newMainArgs()}. + * Instances of MainArgs are immutable and thread safe. It is safe to re-use the same + * MainArgs instance for multiple Scalafix invocations. + * + * @implNote This interface is not intended for extension, the only implementation of this interface + * should live in the Scalafix repository. + */ +public interface ScalafixMainArgs { + + /** + * @param rules The rules passed via the --rules flag matching the syntax provided in + * rules = [ "... " ] in .scalafix.conf files. + */ + ScalafixMainArgs withRules(List rules); + + /** + * @param toolClasspath Custom classpath for classloading and compiling external rules. + * Must be a URLClassLoader (not regular ClassLoader) to support + * compiling sources. + */ + ScalafixMainArgs withToolClasspath(URLClassLoader toolClasspath); + + /** + * @param paths Files and directories to run Scalafix on. The ability to pass in directories + * is primarily supported to make it ergonomic to invoke the command-line interface. + * It's recommended to only pass in files with this API. Directories are recursively + * expanded for files matching the patterns *.scala and *.sbt + * and files that do not match the path matchers provided in {@link #withExcludedPaths(List)}. + */ + ScalafixMainArgs withPaths(List paths); + + /** + * @param matchers Optional list of path matchers to exclude files when expanding directories + * in {@link #withPaths(List)}. + */ + ScalafixMainArgs withExcludedPaths(List matchers); + + /** + * @param path The working directory of where to invoke the command-line interface. + * Primarily used to absolutize relative directories passed via + * {@link ScalafixMainArgs#withPaths(List) } and also to auto-detect the + * location of .scalafix.conf. + */ + ScalafixMainArgs withWorkingDirectory(Path path); + + /** + * @param path Optional path to a .scalafix.conf. If not provided, Scalafix + * will infer such a file from the working directory or fallback to the default + * configuration. + */ + ScalafixMainArgs withConfig(Path path); + + /** + * @param mode The mode to run via --test or --stdout or --auto-suppress-linter-errors + */ + ScalafixMainArgs withMode(ScalafixMainMode mode); + + /** + * @param args Unparsed command-line arguments that are fed directly to main(Array[String]) + */ + ScalafixMainArgs withArgs(List args); + + /** + * @param out The output stream to use for reporting diagnostics while running Scalafix. + * Defaults to System.out. + */ + ScalafixMainArgs withPrintStream(PrintStream out); + + /** + * @param classpath Full Java classpath of the module being fixed. Required for running + * semantic rewrites such as ExpliticResultTypes. Source files that + * are to be fixed must be compiled with the semanticdb-scalac compiler + * plugin and must have corresponding META-INF/semanticdb/../*.semanticdb + * payloads. The dependency classpath must be included as well but dependency + * sources do not have to be compiled with semanticdb-scalac. + */ + ScalafixMainArgs withClasspath(List classpath); + + /** + * @param path The SemanticDB sources path passed via --sourceroot. Must match path + * in -Xplugin:semanticdb:sourceroot:{path} if used. Defaults + * to the current working directory. + */ + ScalafixMainArgs withSourceroot(Path path); + + /** + * @param callback Handler for reported linter messages. If not provided, defaults to printing + * linter messages to the Stdout. + */ + ScalafixMainArgs withMainCallback(ScalafixMainCallback callback); + + + /** + * @param charset Charset for reading source files from disk. Defaults to UTF-8. + */ + ScalafixMainArgs withCharset(Charset charset); +} diff --git a/scalafix-interfaces/src/main/java/scalafix/interfaces/ScalafixMainCallback.java b/scalafix-interfaces/src/main/java/scalafix/interfaces/ScalafixMainCallback.java new file mode 100644 index 000000000..1566004f7 --- /dev/null +++ b/scalafix-interfaces/src/main/java/scalafix/interfaces/ScalafixMainCallback.java @@ -0,0 +1,13 @@ +package scalafix.interfaces; + +/** + * Callback handler for events that happen in the command-line interface. + */ +public interface ScalafixMainCallback { + /** + * Handle a diagnostic event reported by the Scalafix reporter. + * + * @param diagnostic the reported diagnostic. + */ + void reportDiagnostic(ScalafixDiagnostic diagnostic); +} diff --git a/scalafix-interfaces/src/main/java/scalafix/interfaces/ScalafixMainMode.java b/scalafix-interfaces/src/main/java/scalafix/interfaces/ScalafixMainMode.java new file mode 100644 index 000000000..9cdf4d4a8 --- /dev/null +++ b/scalafix-interfaces/src/main/java/scalafix/interfaces/ScalafixMainMode.java @@ -0,0 +1,32 @@ +package scalafix.interfaces; + +/** + * The mode for running the command-line interface. + */ +public enum ScalafixMainMode { + + /** + * Default value, write fixed contents in-place. + */ + IN_PLACE, + + /** + * Report error if fixed contents does not match original file contents. + * + * Does not write to files. + */ + TEST, + + /** + * Print fixed output to stdout. + * + * Does not write to files. + */ + STDOUT, + + /** + * Instead of reporting linter error messages, write suppression comments in-place. + */ + AUTO_SUPPRESS_LINTER_ERRORS, + +} diff --git a/scalafix-interfaces/src/main/java/scalafix/interfaces/ScalafixPosition.java b/scalafix-interfaces/src/main/java/scalafix/interfaces/ScalafixPosition.java new file mode 100644 index 000000000..24bc113af --- /dev/null +++ b/scalafix-interfaces/src/main/java/scalafix/interfaces/ScalafixPosition.java @@ -0,0 +1,53 @@ +package scalafix.interfaces; + +/** + * A source position pointing to a range location in code. + */ +public interface ScalafixPosition { + + /** + * Pretty-print a message at this source location. + * + * @param severity the severity of this message. + * @param message the actual message contents. + * @return a formatted string containing the message, severity and caret + * pointing to the source code location. + */ + String formatMessage(String severity, String message); + + /** + * @return The character offset at the start of this range location. + */ + int startOffset(); + + /** + * @return The 0-based line number of the start of this range location. + */ + int startLine(); + + /** + * @return The 0-based column number of the start of this range location. + */ + int startColumn(); + + /** + * @return The character offset at the end of this range location. + */ + int endOffset(); + + /** + * @return The 0-based line number of the start of this range location. + */ + int endLine(); + + /** + * @return The 0-based column number of the end of this range location. + */ + int endColumn(); + + /** + * @return The source input of this range position. + */ + ScalafixInput input(); + +} diff --git a/scalafix-interfaces/src/main/java/scalafix/interfaces/ScalafixSeverity.java b/scalafix-interfaces/src/main/java/scalafix/interfaces/ScalafixSeverity.java new file mode 100644 index 000000000..fc00c0413 --- /dev/null +++ b/scalafix-interfaces/src/main/java/scalafix/interfaces/ScalafixSeverity.java @@ -0,0 +1,7 @@ +package scalafix.interfaces; + +public enum ScalafixSeverity { + INFO, + WARNING, + ERROR +} diff --git a/scalafix-reflect/src/main/scala/scalafix/internal/reflect/ClasspathOps.scala b/scalafix-reflect/src/main/scala/scalafix/internal/reflect/ClasspathOps.scala index 569a3c239..28915343f 100644 --- a/scalafix-reflect/src/main/scala/scalafix/internal/reflect/ClasspathOps.scala +++ b/scalafix-reflect/src/main/scala/scalafix/internal/reflect/ClasspathOps.scala @@ -28,7 +28,6 @@ object ClasspathOps { /** Process classpath with metacp to build semanticdbs of global symbols. **/ def toMetaClasspath( sclasspath: Classpath, - cacheDirectory: Option[AbsolutePath] = None, parallel: Boolean = false, out: PrintStream = devNull ): Option[Classpath] = { @@ -54,7 +53,6 @@ object ClasspathOps { def newSymbolTable( classpath: Classpath, - cacheDirectory: Option[AbsolutePath] = None, parallel: Boolean = false, out: PrintStream = System.out ): Option[SymbolTable] = { @@ -63,12 +61,14 @@ object ClasspathOps { } } + def thisClassLoader: URLClassLoader = + this.getClass.getClassLoader.asInstanceOf[URLClassLoader] def getCurrentClasspath: String = { - Thread.currentThread.getContextClassLoader match { - case url: java.net.URLClassLoader => - url.getURLs.map(_.getFile).mkString(File.pathSeparator) - case _ => "" - } + this.getClass.getClassLoader + .asInstanceOf[URLClassLoader] + .getURLs + .map(_.getFile) + .mkString(File.pathSeparator) } private val META_INF = Paths.get("META-INF") @@ -99,8 +99,8 @@ object ClasspathOps { Classpath(buffer.result()) } - def toClassLoader(classpath: Classpath): ClassLoader = { + def toClassLoader(classpath: Classpath): URLClassLoader = { val urls = classpath.entries.map(_.toNIO.toUri.toURL).toArray - new URLClassLoader(urls, null) + new URLClassLoader(urls, this.getClass.getClassLoader) } } diff --git a/scalafix-reflect/src/main/scala/scalafix/internal/reflect/RuleCompiler.scala b/scalafix-reflect/src/main/scala/scalafix/internal/reflect/RuleCompiler.scala new file mode 100644 index 000000000..b0022a0b9 --- /dev/null +++ b/scalafix-reflect/src/main/scala/scalafix/internal/reflect/RuleCompiler.scala @@ -0,0 +1,82 @@ +package scalafix.internal.reflect +import java.io.File +import java.nio.file.Paths +import metaconfig.ConfError +import metaconfig.Configured +import metaconfig.Input +import metaconfig.Position +import scala.meta.io.AbsolutePath +import scala.reflect.internal.util.AbstractFileClassLoader +import scala.reflect.internal.util.BatchSourceFile +import scala.tools.nsc.Global +import scala.tools.nsc.Settings +import scala.tools.nsc.io.AbstractFile +import scala.tools.nsc.io.VirtualDirectory +import scala.tools.nsc.reporters.StoreReporter + +class RuleCompiler( + classpath: String, + target: AbstractFile = new VirtualDirectory("(memory)", None)) { + private val settings = new Settings() + settings.deprecation.value = true // enable detailed deprecation warnings + settings.unchecked.value = true // enable detailed unchecked warnings + settings.outputDirs.setSingleOutput(target) + settings.classpath.value = classpath + lazy val reporter = new StoreReporter + private val global = new Global(settings, reporter) + private val classLoader = + new AbstractFileClassLoader(target, this.getClass.getClassLoader) + + def compile(input: Input): Configured[ClassLoader] = { + reporter.reset() + val run = new global.Run + val label = input match { + case Input.File(path, _) => path.toString + case Input.VirtualFile(path, _) => path + case _ => "(input)" + } + run.compileSources( + List(new BatchSourceFile(label, new String(input.chars)))) + val errors = reporter.infos.collect { + case reporter.Info(pos, msg, reporter.ERROR) => + ConfError + .message(msg) + .atPos( + if (pos.isDefined) Position.Range(input, pos.start, pos.end) + else Position.None + ) + .notOk + } + ConfError + .fromResults(errors.toSeq) + .map(_.notOk) + .getOrElse(Configured.Ok(classLoader)) + } +} +object RuleCompiler { + + def defaultClasspath: String = { + defaultClasspathPaths.mkString(File.pathSeparator) + } + + def defaultClasspathPaths: List[AbsolutePath] = { + val classLoader = ClasspathOps.thisClassLoader + val paths = classLoader.getURLs.iterator.map { u => + if (u.getProtocol.startsWith("bootstrap")) { + import java.nio.file._ + val stream = u.openStream + val tmp = Files.createTempFile("bootstrap-" + u.getPath, ".jar") + try { + Files.copy(stream, tmp, StandardCopyOption.REPLACE_EXISTING) + } finally { + stream.close() + } + AbsolutePath(tmp) + } else { + AbsolutePath(Paths.get(u.toURI)) + } + } + paths.toList + } + +} diff --git a/scalafix-reflect/src/main/scala/scalafix/internal/reflect/ScalafixToolbox.scala b/scalafix-reflect/src/main/scala/scalafix/internal/reflect/ScalafixToolbox.scala index 727ba88a0..135e01352 100644 --- a/scalafix-reflect/src/main/scala/scalafix/internal/reflect/ScalafixToolbox.scala +++ b/scalafix-reflect/src/main/scala/scalafix/internal/reflect/ScalafixToolbox.scala @@ -2,24 +2,14 @@ package scalafix.internal.reflect import java.io.File import java.net.URLClassLoader -import java.nio.file.Paths import java.util.function -import scala.reflect.internal.util.AbstractFileClassLoader -import scala.reflect.internal.util.BatchSourceFile -import scala.tools.nsc.Global -import scala.tools.nsc.Settings -import scala.tools.nsc.io.AbstractFile -import scala.tools.nsc.io.VirtualDirectory -import scala.tools.nsc.reporters.StoreReporter -import scalafix.internal.config.MetaconfigPendingUpstream._ -import metaconfig.ConfError import metaconfig.Configured import metaconfig.Input -import metaconfig.Position -import scala.meta.io.AbsolutePath +import scalafix.internal.config.MetaconfigPendingUpstream._ object ScalafixToolbox extends ScalafixToolbox class ScalafixToolbox { + private val ruleCache = new java.util.concurrent.ConcurrentHashMap[ Input, @@ -28,14 +18,16 @@ class ScalafixToolbox { new java.util.concurrent.ConcurrentHashMap[String, RuleCompiler]() private val newCompiler: function.Function[String, RuleCompiler] = new function.Function[String, RuleCompiler] { - override def apply(classpath: String) = new RuleCompiler(classpath) + override def apply(classpath: String) = + new RuleCompiler( + classpath + File.pathSeparator + RuleCompiler.defaultClasspath) } case class CompiledRules(classloader: ClassLoader, fqns: Seq[String]) def getRule( code: Input, - toolClasspath: List[AbsolutePath]): Configured[CompiledRules] = + toolClasspath: URLClassLoader): Configured[CompiledRules] = ruleCache.getOrDefault(code, { val uncached = getRuleUncached(code, toolClasspath) uncached match { @@ -48,87 +40,14 @@ class ScalafixToolbox { def getRuleUncached( code: Input, - toolClasspath: List[AbsolutePath]): Configured[CompiledRules] = + toolClasspath: URLClassLoader): Configured[CompiledRules] = synchronized { - val cp = RuleCompiler.defaultClasspathPaths ++ toolClasspath - val compiler = compilerCache.computeIfAbsent( - cp.mkString(File.pathSeparator), - newCompiler - ) + val classpath = + toolClasspath.getURLs.map(_.getPath).mkString(File.pathSeparator) + val compiler = compilerCache.computeIfAbsent(classpath, newCompiler) ( compiler.compile(code) |@| RuleInstrumentation.getRuleFqn(code.toMeta) ).map(CompiledRules.tupled.apply) } } - -object RuleCompiler { - - def defaultClasspath: String = { - defaultClasspathPaths.mkString(File.pathSeparator) - } - - def defaultClasspathPaths: List[AbsolutePath] = { - getClass.getClassLoader match { - case u: URLClassLoader => - val paths = u.getURLs.iterator.map { u => - if (u.getProtocol.startsWith("bootstrap")) { - import java.nio.file._ - val stream = u.openStream - val tmp = Files.createTempFile("bootstrap-" + u.getPath, ".jar") - Files.copy(stream, tmp, StandardCopyOption.REPLACE_EXISTING) - AbsolutePath(tmp) - } else { - AbsolutePath(Paths.get(u.toURI)) - } - } - paths.toList - case obtained => - throw new IllegalStateException( - s"Classloader mismatch\n" + - s"Expected: this.getClass.getClassloader.isInstanceOf[URLClassLoader]\n" + - s"Obtained: $obtained") - } - } - -} - -class RuleCompiler( - classpath: String, - target: AbstractFile = new VirtualDirectory("(memory)", None)) { - private val settings = new Settings() - settings.deprecation.value = true // enable detailed deprecation warnings - settings.unchecked.value = true // enable detailed unchecked warnings - settings.outputDirs.setSingleOutput(target) - settings.classpath.value = classpath - lazy val reporter = new StoreReporter - private val global = new Global(settings, reporter) - private val classLoader = - new AbstractFileClassLoader(target, this.getClass.getClassLoader) - - def compile(input: Input): Configured[ClassLoader] = { - reporter.reset() - val run = new global.Run - val label = input match { - case Input.File(path, _) => path.toString() - case Input.VirtualFile(label, _) => label - case _ => "(input)" - } - run.compileSources( - List(new BatchSourceFile(label, new String(input.chars)))) - val errors = reporter.infos.collect { - case reporter.Info(pos, msg, reporter.ERROR) => - ConfError - .message(msg) - .atPos( - if (pos.isDefined) Position.Range(input, pos.start, pos.end) - else Position.None - ) - .notOk - } - ConfError - .fromResults(errors.toSeq) - .map(_.notOk) - .getOrElse(Configured.Ok(classLoader)) - } -} diff --git a/scalafix-reflect/src/main/scala/scalafix/reflect/ScalafixReflect.scala b/scalafix-reflect/src/main/scala/scalafix/reflect/ScalafixReflect.scala index 9734f8719..dfb963384 100644 --- a/scalafix-reflect/src/main/scala/scalafix/reflect/ScalafixReflect.scala +++ b/scalafix-reflect/src/main/scala/scalafix/reflect/ScalafixReflect.scala @@ -1,7 +1,6 @@ package scalafix.reflect import metaconfig.ConfDecoder -import scalafix.internal.config._ import scalafix.v0 object ScalafixReflect { @@ -12,9 +11,4 @@ object ScalafixReflect { @deprecated("Use scalafix.v1.RuleDecoder.decoder() instead", "0.6.0") def semantic(index: v0.SemanticdbIndex): ConfDecoder[v0.Rule] = throw new UnsupportedOperationException - - @deprecated("Use scalafix.v1.RuleDecoder.decoder() instead", "0.6.0") - def fromLazySemanticdbIndex( - index: LazySemanticdbIndex): ConfDecoder[v0.Rule] = - throw new UnsupportedOperationException } diff --git a/scalafix-reflect/src/main/scala/scalafix/v1/RuleDecoder.scala b/scalafix-reflect/src/main/scala/scalafix/v1/RuleDecoder.scala index 2312e041b..01aefe01e 100644 --- a/scalafix-reflect/src/main/scala/scalafix/v1/RuleDecoder.scala +++ b/scalafix-reflect/src/main/scala/scalafix/v1/RuleDecoder.scala @@ -14,11 +14,12 @@ import scalafix.internal.reflect.RuleDecoderOps.FromSourceRule import scalafix.internal.reflect.RuleDecoderOps.tryClassload import scalafix.internal.reflect.ScalafixToolbox import scalafix.internal.reflect.ScalafixToolbox.CompiledRules -import scalafix.internal.util.ClassloadRule import scalafix.internal.v1.Rules import scalafix.patch.TreePatch import scalafix.v1 import scala.collection.JavaConverters._ +import scala.meta.io.Classpath +import scalafix.internal.reflect.ClasspathOps /** One-stop shop for loading scalafix rules from strings. */ object RuleDecoder { @@ -34,32 +35,21 @@ object RuleDecoder { */ def fromString( rule: String, + allRules: List[v1.Rule], settings: Settings ): List[Configured[v1.Rule]] = { - lazy val classloader = - if (settings.toolClasspath.isEmpty) ClassloadRule.defaultClassloader - else { - new URLClassLoader( - settings.toolClasspath.iterator.map(_.toURI.toURL).toArray, - ClassloadRule.defaultClassloader - ) - } - val customRules = - ServiceLoader.load(classOf[v1.Rule], classloader) - val allRules = - Iterator(Rules.defaults.iterator, customRules.iterator().asScala).flatten allRules.find(_.name.matches(rule)) match { case Some(r) => Configured.ok(r) :: Nil case None => - fromStringURI(rule, classloader, settings) + fromStringURI(rule, settings.toolClasspath, settings) } } // Attempts to load a rule as if it was a URI, for example 'class:FQN' or 'github:org/repo/v1' private def fromStringURI( rule: String, - classloader: ClassLoader, + classloader: URLClassLoader, settings: Settings ): List[Configured[v1.Rule]] = { val FromSource = new FromSourceRule(settings.cwd) @@ -109,13 +99,17 @@ object RuleDecoder { def decoder(settings: Settings): ConfDecoder[Rules] = new ConfDecoder[Rules] { + private val customRules = + ServiceLoader.load(classOf[v1.Rule], settings.toolClasspath) + private val allRules = + List(Rules.defaults.iterator, customRules.iterator().asScala).flatten override def read(conf: Conf): Configured[Rules] = conf match { case str: Conf.Str => read(Conf.Lst(str :: Nil)) case Conf.Lst(values) => val decoded = values.flatMap { case Conf.Str(value) => - fromString(value, settings).map { rule => + fromString(value, allRules, settings).map { rule => rule.foreach( _.name .reportDeprecationWarning(value, settings.config.reporter)) @@ -154,7 +148,7 @@ object RuleDecoder { */ final class Settings private ( val config: ScalafixConfig, - val toolClasspath: List[AbsolutePath], + val toolClasspath: URLClassLoader, val cwd: AbsolutePath ) { @@ -163,6 +157,10 @@ object RuleDecoder { } def withToolClasspath(value: List[AbsolutePath]): Settings = { + copy(toolClasspath = ClasspathOps.toClassLoader(Classpath(value))) + } + + def withToolClasspath(value: URLClassLoader): Settings = { copy(toolClasspath = value) } @@ -172,7 +170,7 @@ object RuleDecoder { private def copy( config: ScalafixConfig = this.config, - toolClasspath: List[AbsolutePath] = this.toolClasspath, + toolClasspath: URLClassLoader = this.toolClasspath, cwd: AbsolutePath = this.cwd ): Settings = new Settings( @@ -185,7 +183,7 @@ object RuleDecoder { def apply(): Settings = new Settings( ScalafixConfig.default, - Nil, + ClasspathOps.thisClassLoader, PathIO.workingDirectory ) } diff --git a/scalafix-testkit/src/main/scala/scalafix/internal/testkit/AssertDelta.scala b/scalafix-testkit/src/main/scala/scalafix/internal/testkit/AssertDelta.scala new file mode 100644 index 000000000..6794bd64d --- /dev/null +++ b/scalafix-testkit/src/main/scala/scalafix/internal/testkit/AssertDelta.scala @@ -0,0 +1,88 @@ +package scalafix.internal.testkit + +import scala.meta.Position +import scalafix.lint.LintDiagnostic +import scalafix.testkit.DiffAssertions +import scalafix.internal.util.PositionSyntax._ + +// AssertDelta is used to find which Assert is associated with which LintMessage +case class AssertDelta( + assert: CommentAssertion, + lintDiagnostic: LintDiagnostic) { + + private def sameLine(assertPos: Position): Boolean = + assertPos.startLine == lintDiagnostic.position.startLine + + private def sameKey(key: String): Boolean = + lintDiagnostic.id.fullStringID == key + + private val isSameLine: Boolean = + sameLine(assert.anchorPosition) + + private val isCorrect: Boolean = + sameKey(assert.key) && + (assert.caretPosition match { + case Some(carPos) => + (carPos.start == lintDiagnostic.position.start) && + assert.expectedMessage.forall(_.trim == lintDiagnostic.message.trim) + case None => + sameLine(assert.anchorPosition) + }) + + def isMismatch: Boolean = isSameLine && !isCorrect + + def isWrong: Boolean = !isSameLine + + def similarity: String = { + val pos = assert.anchorPosition + + val caretDiff = + assert.caretPosition + .map { carPos => + if (carPos.start != lintDiagnostic.position.start) { + val line = pos.lineContent + + val assertCarret = (" " * carPos.startColumn) + "^-- asserted" + val lintCarret = (" " * lintDiagnostic.position.startColumn) + "^-- reported" + + List( + line, + assertCarret, + lintCarret + ) + } else { + Nil + } + } + .getOrElse(Nil) + + val keyDiff = + if (!sameKey(assert.key)) { + List( + s"""|-${assert.key} + |+${lintDiagnostic.id.fullStringID}""".stripMargin + ) + } else { + Nil + } + + val messageDiff = + assert.expectedMessage + .map { message => + List( + DiffAssertions.compareContents( + message, + lintDiagnostic.message + ) + ) + } + .getOrElse(Nil) + + val result = + caretDiff ++ + keyDiff ++ + messageDiff + + result.mkString("\n") + } +} diff --git a/scalafix-testkit/src/main/scala/scalafix/internal/testkit/AssertDiff.scala b/scalafix-testkit/src/main/scala/scalafix/internal/testkit/AssertDiff.scala new file mode 100644 index 000000000..214606340 --- /dev/null +++ b/scalafix-testkit/src/main/scala/scalafix/internal/testkit/AssertDiff.scala @@ -0,0 +1,189 @@ +package scalafix.internal.testkit + +import scalafix.lint.LintDiagnostic +import scalafix.internal.util.LintSyntax._ + +// Example: +// +// ```scala +// // (R1) (A1) +// Option(1).get /* assert: Disable.get +// ^ (*: caret on wrong offset) +// +// Option.get is the root of all evils +// +// If you must Option.get, wrap the code block with +// // scalafix:off Option.get +// ... +// // scalafix:on Option.get +// */ +// +// // (A2) +// 1 // assert: Disable.get +// +// // (R2) +// Option(1).get +// ``` +// Reported +// ------ +// R1 | R2 | +// | | +// A1 ✓* | ✗ | +// | | +// Asserted | |<-- (∀ wrong = unexpected) +// | | +// -----------------------------+----+ +// |A2 ✗ | ✗ | +// -----------------------------+----+ +// ^ +// +-- (∀ wrong = unreported) +object AssertDiff { + def apply( + reportedLintMessages: List[LintDiagnostic], + expectedLintMessages: List[CommentAssertion]): AssertDiff = { + + val data = + expectedLintMessages + .map { assert => + reportedLintMessages + .map(message => AssertDelta(assert, message)) + .to[IndexedSeq] + } + .to[IndexedSeq] + + if (reportedLintMessages.nonEmpty && expectedLintMessages.nonEmpty) { + val matrix = + new Matrix( + array = data, + _rows = expectedLintMessages.size - 1, + _columns = reportedLintMessages.size - 1 + ) + + val unreported = + matrix.rows + .filter(_.forall(_.isWrong)) + .flatMap(_.headOption.map(_.assert)) + .toList + + val unexpected = + matrix.columns + .filter(_.forall(_.isWrong)) + .flatMap(_.headOption.map(_.lintDiagnostic)) + .toList + + val mismatch = + matrix.cells.filter(_.isMismatch).toList + + AssertDiff( + unreported = unreported, + unexpected = unexpected, + mismatch = mismatch + ) + } else { + AssertDiff( + unreported = expectedLintMessages, + unexpected = reportedLintMessages, + mismatch = List() + ) + } + } + +} +// AssertDiff is the result of comparing CommentAssertion and LintMessage +// +// There is three categories of result: +// +// * unreported: the developper added an assert but no linting was reported +// * unexpected: a linting was reported but the developper did not asserted it +// * missmatch: the developper added an assert and a linting wal reported but they partialy match +// for example, the caret on a multiline assert may be on the wrong offset. +case class AssertDiff( + unreported: List[CommentAssertion], + unexpected: List[LintDiagnostic], + mismatch: List[AssertDelta]) { + + def isFailure: Boolean = + !( + unreported.isEmpty && + unexpected.isEmpty && + mismatch.isEmpty + ) + + override def toString: String = { + val nl = "\n" + + def formatLintDiagnostic(diag: LintDiagnostic): String = { + diag.withMessage("\n" + diag.message).formattedMessage + } + + val elementSeparator = + nl + nl + "---------------------------------------" + nl + nl + + val mismatchBanner = + if (mismatch.isEmpty) "" + else { + """|===========> Mismatch <=========== + | + |""".stripMargin + } + + val showMismatchs = + mismatch + .sortBy(_.lintDiagnostic.position.startLine) + .map { delta => + List( + "Obtained: " + formatLintDiagnostic(delta.lintDiagnostic), + "Expected: " + delta.assert.formattedMessage, + "Diff:", + delta.similarity + ).mkString("", nl, "") + } + .mkString( + mismatchBanner, + elementSeparator, + nl + ) + + val unexpectedBanner = + if (unexpected.isEmpty) "" + else { + """|===========> Unexpected <=========== + | + |""".stripMargin + } + + val showUnexpected = + unexpected + .sortBy(_.position.startLine) + .map(formatLintDiagnostic) + .mkString( + unexpectedBanner, + elementSeparator, + nl + ) + + val unreportedBanner = + if (unreported.isEmpty) "" + else { + """|===========> Unreported <=========== + | + |""".stripMargin + } + + val showUnreported = + unreported + .sortBy(_.anchorPosition.startLine) + .map(_.formattedMessage) + .mkString( + unreportedBanner, + elementSeparator, + nl + ) + + List( + showMismatchs, + showUnexpected, + showUnreported + ).mkString(nl) + } +} diff --git a/scalafix-testkit/src/main/scala/scalafix/internal/testkit/CommentAssertion.scala b/scalafix-testkit/src/main/scala/scalafix/internal/testkit/CommentAssertion.scala index bf791b142..1eba12589 100644 --- a/scalafix-testkit/src/main/scala/scalafix/internal/testkit/CommentAssertion.scala +++ b/scalafix-testkit/src/main/scala/scalafix/internal/testkit/CommentAssertion.scala @@ -1,10 +1,8 @@ package scalafix.internal.testkit import scala.meta._ -import scalafix.v0._ -import scala.meta.internal.ScalametaInternals import scala.util.matching.Regex -import scalafix.testkit.DiffAssertions +import scalafix.internal.util.PositionSyntax._ // CommentAssertion are the bread and butter of testkit they // assert the line position and the category id of the lint message. @@ -38,11 +36,12 @@ case class CommentAssertion( expectedMessage: Option[String]) { def formattedMessage: String = - ScalametaInternals.formatMessage( - caretPosition.getOrElse(anchorPosition), - "error", - expectedMessage.map("\n" + _).getOrElse("") - ) + caretPosition + .getOrElse(anchorPosition) + .formatMessage( + "error", + expectedMessage.map("\n" + _).getOrElse("") + ) } object CommentAssertion { @@ -114,288 +113,3 @@ object MultiLineAssertExtractor { } } } - -// AssertDelta is used to find which Assert is associated with which LintMessage -case class AssertDelta(assert: CommentAssertion, lintMessage: LintMessage) { - - private def sameLine(assertPos: Position): Boolean = - assertPos.startLine == lintMessage.position.startLine - - private def lintKey: String = - lintMessage.category.id - - private def sameKey(key: String): Boolean = - lintKey == key - - private val isSameLine: Boolean = - sameLine(assert.anchorPosition) - - private val isCorrect: Boolean = - sameKey(assert.key) && - (assert.caretPosition match { - case Some(carPos) => - (carPos.start == lintMessage.position.start) && - assert.expectedMessage - .map(_.trim == lintMessage.message.trim) - .getOrElse(true) - case None => - sameLine(assert.anchorPosition) - }) - - def isMismatch: Boolean = isSameLine && !isCorrect - - def isWrong: Boolean = !isSameLine - - def similarity: String = { - val pos = assert.anchorPosition - - val caretDiff = - assert.caretPosition - .map { carPos => - if (carPos.start != lintMessage.position.start) { - val line = - Position - .Range(pos.input, pos.start - pos.startColumn, pos.start) - .text - - val assertCarret = (" " * carPos.startColumn) + "^-- asserted" - val lintCarret = (" " * lintMessage.position.startColumn) + "^-- reported" - - List( - line, - assertCarret, - lintCarret - ) - } else { - Nil - } - } - .getOrElse(Nil) - - val keyDiff = - if (!sameKey(assert.key)) { - List( - s"""|-${assert.key} - |+$lintKey""".stripMargin - ) - } else { - Nil - } - - val messageDiff = - assert.expectedMessage - .map( - message => - List( - DiffAssertions.compareContents( - message, - lintMessage.message - ) - )) - .getOrElse(Nil) - - val result = - caretDiff ++ - keyDiff ++ - messageDiff - - result.mkString("\n") - } -} - -// AssertDiff is the result of comparing CommentAssertion and LintMessage -// -// There is three categories of result: -// -// * unreported: the developper added an assert but no linting was reported -// * unexpected: a linting was reported but the developper did not asserted it -// * missmatch: the developper added an assert and a linting wal reported but they partialy match -// for example, the caret on a multiline assert may be on the wrong offset. -case class AssertDiff( - unreported: List[CommentAssertion], - unexpected: List[LintMessage], - missmatch: List[AssertDelta]) { - - def isFailure: Boolean = - !( - unreported.isEmpty && - unexpected.isEmpty && - missmatch.isEmpty - ) - - override def toString: String = { - val nl = "\n" - - def formatLintMessage(lintMessage: LintMessage): String = { - ScalametaInternals.formatMessage( - lintMessage.position, - s"error: [${lintMessage.category.id}]", - nl + lintMessage.message - ) - } - - val elementSeparator = - nl + nl + "---------------------------------------" + nl + nl - - val missmatchBanner = - if (missmatch.isEmpty) "" - else { - """|===========> Mismatch <=========== - | - |""".stripMargin - } - - val showMismatchs = - missmatch - .sortBy(_.lintMessage.position.startLine) - .map( - delta => - List( - "Obtained: " + formatLintMessage(delta.lintMessage), - "Expected: " + delta.assert.formattedMessage, - "Diff:", - delta.similarity - ).mkString("", nl, "")) - .mkString( - missmatchBanner, - elementSeparator, - nl - ) - - val unexpectedBanner = - if (unexpected.isEmpty) "" - else { - """|===========> Unexpected <=========== - | - |""".stripMargin - } - - val showUnexpected = - unexpected - .sortBy(_.position.startLine) - .map(formatLintMessage) - .mkString( - unexpectedBanner, - elementSeparator, - nl - ) - - val unreportedBanner = - if (unreported.isEmpty) "" - else { - """|===========> Unreported <=========== - | - |""".stripMargin - } - - val showUnreported = - unreported - .sortBy(_.anchorPosition.startLine) - .map(_.formattedMessage) - .mkString( - unreportedBanner, - elementSeparator, - nl - ) - - List( - showMismatchs, - showUnexpected, - showUnreported - ).mkString(nl) - } -} - -// Example: -// -// ```scala -// // (R1) (A1) -// Option(1).get /* assert: Disable.get -// ^ (*: caret on wrong offset) -// -// Option.get is the root of all evils -// -// If you must Option.get, wrap the code block with -// // scalafix:off Option.get -// ... -// // scalafix:on Option.get -// */ -// -// // (A2) -// 1 // assert: Disable.get -// -// // (R2) -// Option(1).get -// ``` -// Reported -// ------ -// R1 | R2 | -// | | -// A1 ✓* | ✗ | -// | | -// Asserted | |<-- (∀ wrong = unexpected) -// | | -// -----------------------------+----+ -// |A2 ✗ | ✗ | -// -----------------------------+----+ -// ^ -// +-- (∀ wrong = unreported) -object AssertDiff { - def apply( - reportedLintMessages: List[LintMessage], - expectedLintMessages: List[CommentAssertion]): AssertDiff = { - - val data = - expectedLintMessages - .map( - assert => - reportedLintMessages - .map(message => AssertDelta(assert, message)) - .to[IndexedSeq]) - .to[IndexedSeq] - - if (reportedLintMessages.nonEmpty && expectedLintMessages.nonEmpty) { - val matrix = - new Matrix( - array = data, - rows = expectedLintMessages.size - 1, - columns = reportedLintMessages.size - 1 - ) - - val unreported = - matrix.rows - .filter(_.forall(_.isWrong)) - .flatMap(_.headOption.map(_.assert)) - .toList - - val unexpected = - matrix.columns - .filter(_.forall(_.isWrong)) - .flatMap(_.headOption.map(_.lintMessage)) - .toList - - val missmatch = - matrix.cells.filter(_.isMismatch).toList - - AssertDiff( - unreported = unreported, - unexpected = unexpected, - missmatch = missmatch - ) - } else { - AssertDiff( - unreported = expectedLintMessages, - unexpected = reportedLintMessages, - missmatch = List() - ) - } - } -} - -class Matrix[T](array: IndexedSeq[IndexedSeq[T]], rows: Int, columns: Int) { - def row(r: Int): IndexedSeq[T] = array(r) - def column(c: Int): IndexedSeq[T] = (0 to rows).map(i => array(i)(c)) - def rows: IndexedSeq[IndexedSeq[T]] = array - def columns: IndexedSeq[IndexedSeq[T]] = (0 to columns).map(column) - def cells: IndexedSeq[T] = array.flatten -} diff --git a/scalafix-testkit/src/main/scala/scalafix/internal/testkit/Matrix.scala b/scalafix-testkit/src/main/scala/scalafix/internal/testkit/Matrix.scala new file mode 100644 index 000000000..44b7b901b --- /dev/null +++ b/scalafix-testkit/src/main/scala/scalafix/internal/testkit/Matrix.scala @@ -0,0 +1,9 @@ +package scalafix.internal.testkit + +class Matrix[T](array: IndexedSeq[IndexedSeq[T]], _rows: Int, _columns: Int) { + def row(r: Int): IndexedSeq[T] = array(r) + def column(c: Int): IndexedSeq[T] = (0 to _rows).map(i => array(i)(c)) + def rows: IndexedSeq[IndexedSeq[T]] = array + def columns: IndexedSeq[IndexedSeq[T]] = (0 to _columns).map(column) + def cells: IndexedSeq[T] = array.flatten +} diff --git a/scalafix-testkit/src/main/scala/scalafix/testkit/DiffAssertions.scala b/scalafix-testkit/src/main/scala/scalafix/testkit/DiffAssertions.scala index a90d9fdbd..ebb5082a8 100644 --- a/scalafix-testkit/src/main/scala/scalafix/testkit/DiffAssertions.scala +++ b/scalafix-testkit/src/main/scala/scalafix/testkit/DiffAssertions.scala @@ -48,7 +48,9 @@ trait DiffAssertions extends FunSuiteLike { obtained: String, diff: String) extends TestFailedException( - title + "\n" + error2message(obtained, expected), + title + "\n--- expected\n+++ obtained\n" + error2message( + obtained, + expected), 3) def error2message(obtained: String, expected: String): String = { diff --git a/scalafix-tests/unit/src/main/scala/scalafix/test/ScalafixJarFetcher.scala b/scalafix-tests/unit/src/main/scala/scalafix/test/ScalafixJarFetcher.scala deleted file mode 100644 index 21a7f0180..000000000 --- a/scalafix-tests/unit/src/main/scala/scalafix/test/ScalafixJarFetcher.scala +++ /dev/null @@ -1,53 +0,0 @@ -package scalafix.internal.sbt - -import java.io.File -import java.io.OutputStreamWriter -import coursier.MavenRepository -import coursier.util.Gather -import coursier.util.Task -import scala.concurrent.ExecutionContext.Implicits.global - -private[scalafix] object ScalafixJarFetcher { - private val SonatypeSnapshots: MavenRepository = - MavenRepository("https://oss.sonatype.org/content/repositories/snapshots") - private val MavenCentral: MavenRepository = - MavenRepository("https://repo1.maven.org/maven2") - - def fetchJars(org: String, artifact: String, version: String): List[File] = - this.synchronized { - import coursier._ - val res = Resolution(Set(Dependency(Module(org, artifact), version))) - val repositories: List[Repository] = List( - Some(Cache.ivy2Local), - Some(MavenCentral), - if (version.endsWith("-SNAPSHOT")) Some(SonatypeSnapshots) - else None - ).flatten - - val term = new TermDisplay(new OutputStreamWriter(System.err), true) - term.init() - val fetch = - Fetch.from(repositories, Cache.fetch[Task](logger = Some(term))) - val resolution = res.process.run(fetch).unsafeRun() - val errors = resolution.errors - if (errors.nonEmpty) { - sys.error(errors.mkString("\n")) - } - val localArtifacts = Gather[Task] - .gather( - resolution.artifacts.map(artifact => Cache.file[Task](artifact).run) - ) - .unsafeRun() - val jars = localArtifacts.flatMap { - case Left(e) => - throw new IllegalArgumentException(e.describe) - case Right(jar) if jar.getName.endsWith(".jar") => - jar :: Nil - case _ => - Nil - } - term.stop() - jars.toList - } - -} diff --git a/scalafix-tests/unit/src/main/scala/scalafix/test/StringFS.scala b/scalafix-tests/unit/src/main/scala/scalafix/test/StringFS.scala index 3724d59c1..2e01b1121 100644 --- a/scalafix-tests/unit/src/main/scala/scalafix/test/StringFS.scala +++ b/scalafix-tests/unit/src/main/scala/scalafix/test/StringFS.scala @@ -1,6 +1,8 @@ package scalafix.test import java.io.File +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets import java.nio.file.Files import scala.meta.AbsolutePath @@ -10,7 +12,9 @@ object StringFS { * The inverse of [[dir2string]]. Given a string representation creates the * necessary files/directories with respective file contents. */ - def string2dir(layout: String): AbsolutePath = { + def string2dir( + layout: String, + charset: Charset = StandardCharsets.UTF_8): AbsolutePath = { val root = Files.createTempDirectory("root") if (!layout.isEmpty) { layout.split("(?=\n/)").foreach { row => @@ -18,7 +22,7 @@ object StringFS { case path :: contents :: Nil => val file = root.resolve(path.stripPrefix("/")) file.getParent.toFile.mkdirs() - Files.write(file, contents.getBytes) + Files.write(file, contents.getBytes(charset)) case els => throw new IllegalArgumentException( s"Unable to split argument info path/contents! \n$els") @@ -38,7 +42,9 @@ object StringFS { * /target/scala-2.11/foo.class * ^!*@#@!*#&@*!&#^ */ - def dir2string(file: AbsolutePath): String = { + def dir2string( + file: AbsolutePath, + charset: Charset = StandardCharsets.UTF_8): String = { import scala.collection.JavaConverters._ Files .walk(file.toNIO) @@ -48,7 +54,7 @@ object StringFS { .toArray .sorted .map { path => - val contents = new String(Files.readAllBytes(path)) + val contents = new String(Files.readAllBytes(path), charset) s"""|/${file.toNIO.relativize(path)} |$contents""".stripMargin } diff --git a/scalafix-tests/unit/src/test/scala-2.12/scalafix/tests/cli/ScalafixImplSuite.scala b/scalafix-tests/unit/src/test/scala-2.12/scalafix/tests/cli/ScalafixImplSuite.scala new file mode 100644 index 000000000..30b7dd4ff --- /dev/null +++ b/scalafix-tests/unit/src/test/scala-2.12/scalafix/tests/cli/ScalafixImplSuite.scala @@ -0,0 +1,207 @@ +package scalafix.tests.cli + +import com.geirsson.coursiersmall.CoursierSmall +import com.geirsson.coursiersmall.Dependency +import com.geirsson.coursiersmall.Settings +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.FileOutputStream +import java.io.PrintStream +import java.net.URLClassLoader +import java.nio.channels.Channels +import java.nio.charset.StandardCharsets +import java.nio.file.FileSystems +import java.nio.file.Files +import org.scalatest.FunSuite +import scalafix.Versions +import scalafix.{interfaces => i} +import scala.collection.JavaConverters._ +import scala.meta.internal.semanticdb.scalac.SemanticdbPlugin +import scala.meta.io.AbsolutePath +import scala.meta.io.Classpath +import scalafix.interfaces.ScalafixDiagnostic +import scalafix.interfaces.ScalafixException +import scalafix.interfaces.ScalafixMainCallback +import scalafix.interfaces.ScalafixMainMode +import scalafix.internal.reflect.ClasspathOps +import scalafix.internal.reflect.RuleCompiler +import scalafix.test.StringFS +import scalafix.testkit.DiffAssertions + +class ScalafixImplSuite extends FunSuite with DiffAssertions { + def semanticdbPluginPath(): String = { + // Copy-pasted code from metac command-line tool + // https://github.com/scalameta/scalameta/blob/9f15793aae3cb6a00e1e2d6bcbf13e9c234ea91f/semanticdb/metac/src/main/scala/scala/meta/internal/metac/Main.scala#L14-L23 + val manifestDir = Files.createTempDirectory("semanticdb-scalac_") + val resourceUrl = + classOf[SemanticdbPlugin].getResource("/scalac-plugin.xml") + val resourceChannel = Channels.newChannel(resourceUrl.openStream()) + val manifestStream = new FileOutputStream( + manifestDir.resolve("scalac-plugin.xml").toFile) + manifestStream.getChannel.transferFrom(resourceChannel, 0, Long.MaxValue) + manifestStream.close() + val pluginClasspath = classOf[SemanticdbPlugin].getClassLoader match { + case null => manifestDir.toString + case cl: URLClassLoader => + cl.getURLs.map(_.getFile).mkString(File.pathSeparator) + case cl => sys.error(s"unsupported classloader: $cl") + } + pluginClasspath + } + def scalaLibrary: AbsolutePath = + RuleCompiler.defaultClasspathPaths + .find(_.toNIO.getFileName.toString.contains("scala-library")) + .getOrElse { + throw new IllegalStateException("Unable to detect scala-library.jar") + } + + test("versions") { + val api = i.Scalafix.classloadInstance(this.getClass.getClassLoader) + assert(api.scalafixVersion() == Versions.version) + assert(api.scalametaVersion() == Versions.scalameta) + assert(api.scala211() == Versions.scala211) + assert(api.scala212() == Versions.scala212) + assert( + api + .supportedScalaVersions() + .sameElements(Versions.supportedScalaVersions) + ) + val help = api.mainHelp(80) + assert(help.contains("Usage: scalafix")) + } + + test("error") { + val cl = new URLClassLoader(Array()) + val ex = intercept[ScalafixException] { + i.Scalafix.classloadInstance(cl) + } + assert(ex.getCause.isInstanceOf[ClassNotFoundException]) + } + + test("runMain") { + // This is a full integration test that stresses the full breadth of the scalafix-interfaces API + val api = i.Scalafix.classloadInstance(this.getClass.getClassLoader) + // Assert that non-ascii characters read into "?" + val charset = StandardCharsets.US_ASCII + val cwd = StringFS + .string2dir( + """|/src/Semicolon.scala + | + |object Semicolon { + | val a = 1; // みりん þæö + | implicit val b = List(1) + | def main { println(42) } + |} + | + |/src/Excluded.scala + |object Excluded { + | val a = 1; + |} + """.stripMargin, + charset + ) + .toNIO + val d = cwd.resolve("out") + val src = cwd.resolve("src") + Files.createDirectories(d) + val semicolon = src.resolve("Semicolon.scala") + val excluded = src.resolve("Excluded.scala") + val dependency = + new Dependency("com.geirsson", "example-scalafix-rule_2.12", "1.1.0") + val settings = new Settings().withDependencies(List(dependency)) + // This rule is published to Maven Central to simplify testing --tool-classpath. + val toolClasspathJars = CoursierSmall.fetch(settings) + val toolClasspath = ClasspathOps.toClassLoader( + Classpath(toolClasspathJars.map(jar => AbsolutePath(jar)))) + val scalacOptions = Array[String]( + "-Yrangepos", + s"-Xplugin:${semanticdbPluginPath()}", + "-Xplugin-require:semanticdb", + "-classpath", + scalaLibrary.toString, + s"-P:semanticdb:sourceroot:$src", + "-d", + d.toString, + semicolon.toString, + excluded.toString + ) + val compileSucceeded = scala.tools.nsc.Main.process(scalacOptions) + assert(compileSucceeded) + val buf = List.newBuilder[ScalafixDiagnostic] + val callback = new ScalafixMainCallback { + override def reportDiagnostic(diagnostic: ScalafixDiagnostic): Unit = { + buf += diagnostic + } + } + val out = new ByteArrayOutputStream() + val relativePath = cwd.relativize(semicolon) + val args = api + .newMainArgs() + .withArgs(List("--settings.DisableSyntax.noSemicolons", "true").asJava) + .withCharset(charset) + .withClasspath(List(d, scalaLibrary.toNIO).asJava) + .withSourceroot(src) + .withWorkingDirectory(cwd) + .withPaths(List(relativePath.getParent).asJava) + .withExcludedPaths( + List( + FileSystems.getDefault.getPathMatcher("glob:**Excluded.scala") + ).asJava + ) + .withMainCallback(callback) + .withRules( + List( + "DisableSyntax", // syntactic linter + "ProcedureSyntax", // syntactic rewrite + "ExplicitResultTypes", // semantic rewrite + "class:fix.Examplescalafixrule_v1" // --tool-classpath + ).asJava + ) + .withPrintStream(new PrintStream(out)) + .withMode(ScalafixMainMode.TEST) + .withToolClasspath(toolClasspath) + val errors = api.runMain(args).toList.map(_.toString).sorted + val stdout = fansi + .Str(out.toString(charset.name())) + .plainText + .replaceAllLiterally(semicolon.toString, relativePath.toString) + .replace('\\', '/') // for windows + .lines + .filterNot(_.trim.isEmpty) + .mkString("\n") + assert(errors == List("LinterError", "TestError"), stdout) + val linterDiagnostics = buf + .result() + .map { d => + d.position() + .get() + .formatMessage(d.severity().toString, d.message()) + } + .mkString("\n\n") + .replaceAllLiterally(semicolon.toString, relativePath.toString) + .replace('\\', '/') // for windows + assertNoDiff( + linterDiagnostics, + """|src/Semicolon.scala:3:12: ERROR: semicolons are disabled + | val a = 1; // ??? ??? + | ^ + """.stripMargin + ) + assertNoDiff( + stdout, + """|--- src/Semicolon.scala + |+++ + |@@ -1,6 +1,7 @@ + | object Semicolon { + | val a = 1; // ??? ??? + |- implicit val b = List(1) + |- def main { println(42) } + |+ implicit val b: _root_.scala.collection.immutable.List[_root_.scala.Int] = List(1) + |+ def main: Unit = { println(42) } + | } + |+// Hello world! + |""".stripMargin + ) + } + +} diff --git a/scalafix-tests/unit/src/test/scala/scalafix/tests/cli/BaseCliSuite.scala b/scalafix-tests/unit/src/test/scala/scalafix/tests/cli/BaseCliSuite.scala index cb8db9f27..15eb0c1fa 100644 --- a/scalafix-tests/unit/src/test/scala/scalafix/tests/cli/BaseCliSuite.scala +++ b/scalafix-tests/unit/src/test/scala/scalafix/tests/cli/BaseCliSuite.scala @@ -218,4 +218,10 @@ trait BaseCliSuite extends FunSuite with DiffAssertions { } } + def runMain(args: Array[String], cwd: Path): (String, ExitStatus) = { + val out = new ByteArrayOutputStream() + val exit = Main.run(args, cwd, new PrintStream(out)) + (fansi.Str(out.toString()).plainText, exit) + } + } diff --git a/scalafix-tests/unit/src/test/scala/scalafix/tests/cli/CliGitDiffSuite.scala b/scalafix-tests/unit/src/test/scala/scalafix/tests/cli/CliGitDiffSuite.scala index 9832cda99..ebaeeedbc 100644 --- a/scalafix-tests/unit/src/test/scala/scalafix/tests/cli/CliGitDiffSuite.scala +++ b/scalafix-tests/unit/src/test/scala/scalafix/tests/cli/CliGitDiffSuite.scala @@ -43,7 +43,7 @@ class CliGitDiffSuite extends FunSuite with DiffAssertions { val expected = s"""|$newCodeAbsPath:3:3: error: [DisableSyntax.keywords.var] var is disabled | var newVar = 1 - | ^ + | ^^^ |""".stripMargin assertNoDiff(obtained, expected) @@ -83,7 +83,7 @@ class CliGitDiffSuite extends FunSuite with DiffAssertions { val expected = s"""|$oldCodeAbsPath:7:3: error: [DisableSyntax.keywords.var] var is disabled | var newVar = 2 - | ^ + | ^^^ |""".stripMargin assertNoDiff(obtained, expected) @@ -124,7 +124,7 @@ class CliGitDiffSuite extends FunSuite with DiffAssertions { val expected = s"""|$newCodeAbsPath:5:3: error: [DisableSyntax.keywords.var] var is disabled | var newVar = 2 - | ^ + | ^^^ |""".stripMargin assertNoDiff(obtained, expected) @@ -181,7 +181,7 @@ class CliGitDiffSuite extends FunSuite with DiffAssertions { val expected = s"""|$newCodeAbsPath:5:3: error: [DisableSyntax.keywords.var] var is disabled | var newVar = 2 - | ^ + | ^^^ |""".stripMargin assertNoDiff(obtained, expected) diff --git a/scalafix-tests/unit/src/test/scala/scalafix/tests/cli/CliSemanticSuite.scala b/scalafix-tests/unit/src/test/scala/scalafix/tests/cli/CliSemanticSuite.scala index b6f8c2c65..123125e40 100644 --- a/scalafix-tests/unit/src/test/scala/scalafix/tests/cli/CliSemanticSuite.scala +++ b/scalafix-tests/unit/src/test/scala/scalafix/tests/cli/CliSemanticSuite.scala @@ -44,15 +44,23 @@ class CliSemanticSuite extends BaseCliSuite { } ) - checkSemantic( - name = "MissingSemanticDB", - args = Array(), // no --classpath - expectedExit = ExitStatus.MissingSemanticdbError, - outputAssert = { out => - assert(out.contains("SemanticDB not found: ")) - assert(out.contains(removeImportsPath.toNIO.getFileName.toString)) - } - ) + test("MissingSemanticDB") { + val cwd = Files.createTempDirectory("scalafix") + val name = "MissingSemanticDB.scala" + Files.createFile(cwd.resolve(name)) + val (out, exit) = runMain( + Array( + // no --classpath + "-r", + "RemoveUnusedImports", + name + ), + cwd + ) + assert(exit.is(ExitStatus.MissingSemanticdbError), exit.toString) + assert(out.contains("SemanticDB not found: ")) + assert(out.contains(name)) + } checkSemantic( name = "StaleSemanticDB", diff --git a/scalafix-tests/unit/src/test/scala/scalafix/tests/cli/CliSyntacticSuite.scala b/scalafix-tests/unit/src/test/scala/scalafix/tests/cli/CliSyntacticSuite.scala index c1ad018b4..80d58ece5 100644 --- a/scalafix-tests/unit/src/test/scala/scalafix/tests/cli/CliSyntacticSuite.scala +++ b/scalafix-tests/unit/src/test/scala/scalafix/tests/cli/CliSyntacticSuite.scala @@ -210,7 +210,7 @@ class CliSyntacticSuite extends BaseCliSuite { out.endsWith( """|a.scala:1:1: error: expected class or object definition |objec bar - |^ + |^^^^^ |""".stripMargin ) ) @@ -301,22 +301,4 @@ class CliSyntacticSuite extends BaseCliSuite { expectedExit = ExitStatus.Ok ) - check( - name = "--format sbt", - originalLayout = s"""/foobar.scala - |$original""".stripMargin, - args = Array( - "--format", - "sbt", - "-r", - "scala:scalafix.tests.cli.LintError", - "foobar.scala" - ), - expectedLayout = s"""/foobar.scala - |$original""".stripMargin, - expectedExit = ExitStatus.LinterError, - outputAssert = { out => - assert(out.contains("[error] ")) - } - ) } diff --git a/scalafix-tests/unit/src/test/scala/scalafix/tests/core/PositionSuite.scala b/scalafix-tests/unit/src/test/scala/scalafix/tests/core/PositionSuite.scala new file mode 100644 index 000000000..cba731b41 --- /dev/null +++ b/scalafix-tests/unit/src/test/scala/scalafix/tests/core/PositionSuite.scala @@ -0,0 +1,66 @@ +package scalafix.tests.core + +import org.scalatest.FunSuite +import scala.meta.inputs.Input +import scala.meta.inputs.Position +import scalafix.testkit.DiffAssertions +import scalafix.internal.util.PositionSyntax._ + +class PositionSuite extends FunSuite with DiffAssertions { + + val startMarker = '→' + val stopMarker = '←' + + def check(name: String, original: String, expected: String): Unit = { + test(name) { + require( + original.count(_ == startMarker) == 1, + s"Original must contain one $startMarker") + require( + original.count(_ == stopMarker) == 1, + s"Original must contain one $stopMarker") + val start = original.indexOf(startMarker) + val end = original.indexOf(stopMarker) + val text = new StringBuilder() + .append(original.substring(0, start)) + .append(original.substring(start + 1, end)) + .append(original.substring(end + 1)) + .toString + val input = Input.VirtualFile(name, text) + val adjustedEnd = end - 1 // adjust for dropped "@@" + val pos = Position.Range(input, start, adjustedEnd) + assertNoDiff(pos.formatMessage("", ""), expected) + } + } + + check( + "single-line", + """ + |object A { + | →val x = 1← // this is x + |}""".stripMargin, + """single-line:3:3: + | val x = 1 // this is x + | ^^^^^^^^^ + """.stripMargin + ) + + check( + "multi-line", + """ + |object A { + | →val x = + | 1← // this is x + |}""".stripMargin, + """multi-line:3:3: + | val x = + | ^ + """.stripMargin + ) + + test("Position.None") { + val obtained = Position.None.formatMessage("error", "Boo") + assert(obtained == "error: Boo") + } + +} diff --git a/scalafix-tests/unit/src/test/scala/scalafix/tests/reflect/ToolClasspathSuite.scala b/scalafix-tests/unit/src/test/scala/scalafix/tests/reflect/ToolClasspathSuite.scala index eaa2f0f2b..3a6483ec2 100644 --- a/scalafix-tests/unit/src/test/scala/scalafix/tests/reflect/ToolClasspathSuite.scala +++ b/scalafix-tests/unit/src/test/scala/scalafix/tests/reflect/ToolClasspathSuite.scala @@ -1,11 +1,11 @@ package scalafix.tests.reflect -import java.io.File import java.nio.file.Files import scala.reflect.io.Directory import scala.reflect.io.PlainDirectory import scalafix.internal.reflect.RuleCompiler import scalafix.internal.tests.utils.SkipWindows +import com.geirsson.coursiersmall._ import metaconfig.Conf import scala.meta.io.AbsolutePath import org.scalatest.BeforeAndAfterAll @@ -20,11 +20,12 @@ class ToolClasspathSuite extends FunSuite with BeforeAndAfterAll { .split("\\.") .take(2) .mkString(".") - val jars: List[File] = scalafix.internal.sbt.ScalafixJarFetcher.fetchJars( + val dependency = new Dependency( "com.geirsson", "scalafmt-core_" + scalaBinaryVersion, - "1.2.0" - ) + "1.2.0") + val settings = new Settings().withDependencies(List(dependency)) + val jars = CoursierSmall.fetch(settings) scalafmtClasspath = jars.map(AbsolutePath(_)) } diff --git a/scalafix-tests/unit/src/test/scala/scalafix/tests/testkit/AssertDeltaSuite.scala b/scalafix-tests/unit/src/test/scala/scalafix/tests/testkit/AssertDeltaSuite.scala index c29e8cbaf..8be44f6cf 100644 --- a/scalafix-tests/unit/src/test/scala/scalafix/tests/testkit/AssertDeltaSuite.scala +++ b/scalafix-tests/unit/src/test/scala/scalafix/tests/testkit/AssertDeltaSuite.scala @@ -5,13 +5,17 @@ import scala.meta.Position import scala.meta.dialects.Scala212 import scala.meta.inputs.Input import scala.meta.parsers.Parse +import scalafix.internal.config.ScalafixConfig import scalafix.internal.testkit.AssertDiff import scalafix.internal.testkit.CommentAssertion import scalafix.internal.tests.utils.SkipWindows import scalafix.lint.LintCategory +import scalafix.lint.LintDiagnostic import scalafix.lint.LintMessage import scalafix.lint.LintSeverity +import scalafix.rule.RuleName import scalafix.testkit.DiffAssertions +import scalafix.internal.util.LintSyntax._ class AssertDeltaSuite() extends FunSuite with DiffAssertions { test("associate assert and reported message", SkipWindows) { @@ -39,16 +43,16 @@ class AssertDeltaSuite() extends FunSuite with DiffAssertions { |}""".stripMargin ) - def disable(offset: Int): LintMessage = + def disable(offset: Int): LintDiagnostic = LintMessage( message = "Option.get is the root of all evils", position = Position.Range(input, offset, offset), category = LintCategory( - id = "Disable.get", + id = "get", explanation = "", severity = LintSeverity.Error ) - ) + ).toDiagnostic(RuleName("Disable"), ScalafixConfig.default) // start offset of all `.get` val reportedLintMessages = List( @@ -81,7 +85,7 @@ class AssertDeltaSuite() extends FunSuite with DiffAssertions { | Option(1).get /* assert: Disable.get | ^ |Diff: - | Option(1).get + | Option(1).get /* assert: Disable.get | ^-- asserted | ^-- reported | @@ -92,9 +96,9 @@ class AssertDeltaSuite() extends FunSuite with DiffAssertions { |Option.get is the root of all evils | Option(2).get // assert: Disable.foo | ^ - |Expected: foo/bar/Disable.scala:7:17: error: + |Expected: foo/bar/Disable.scala:7:17: error | Option(2).get // assert: Disable.foo - | ^ + | ^^^^^^^^^^^^^^^^^^^^^^ |Diff: |-Disable.foo |+Disable.get @@ -122,23 +126,23 @@ class AssertDeltaSuite() extends FunSuite with DiffAssertions { | |===========> Unreported <=========== | - |foo/bar/Disable.scala:9:5: error: + |foo/bar/Disable.scala:9:5: error | 3 // assert: Disable.get - | ^ + | ^^^^^^^^^^^^^^^^^^^^^^ | |--------------------------------------- | - |foo/bar/Disable.scala:13:5: error: + |foo/bar/Disable.scala:13:5: error | 5 // assert: Disable.get - | ^ + | ^^^^^^^^^^^^^^^^^^^^^^ | |--------------------------------------- | - |foo/bar/Disable.scala:17:5: error: + |foo/bar/Disable.scala:17:5: error | 7 // assert: Disable.get - | ^ + | ^^^^^^^^^^^^^^^^^^^^^^ |""".stripMargin - assertNoDiff(expected, obtained.replaceAllLiterally(" \n", "\n")) + assertNoDiff(expected, obtained) } }