diff --git a/build.sbt b/build.sbt index 377000daa..a7929b158 100644 --- a/build.sbt +++ b/build.sbt @@ -144,11 +144,10 @@ val cli = MultiScalaProject( "cli", _.settings( isFullCrossVersion, - mainClass in assembly := Some("scalafix.cli.Cli"), + mainClass in assembly := Some("scalafix.v1.Main"), assemblyJarName in assembly := "scalafix.jar", libraryDependencies ++= Seq( "com.github.alexarchambault" %% "case-app" % "1.2.0", - "org.typelevel" %% "paiges-core" % "0.2.0", "com.martiansoftware" % "nailgun-server" % "0.9.1", jgit, "ch.qos.logback" % "logback-classic" % "1.2.3", diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 6e6e3e933..6b0eda48b 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -4,7 +4,7 @@ import org.scalajs.sbtplugin.ScalaJSPlugin.autoImport._ object Dependencies { val scalametaV = "4.0.0-M1" - val metaconfigV = "0.7.2" + val metaconfigV = "0.8.3" def dotty = "0.1.1-bin-20170530-f8f52cc-NIGHTLY" def scala210 = "2.10.6" // NOTE(olafur) downgraded from 2.11.12 and 2.12.4 because of non-reproducible error diff --git a/scalafix-cli/src/main/scala/scalafix/cli/Cli.scala b/scalafix-cli/src/main/scala/scalafix/cli/Cli.scala index af04b6304..845cd6312 100644 --- a/scalafix-cli/src/main/scala/scalafix/cli/Cli.scala +++ b/scalafix-cli/src/main/scala/scalafix/cli/Cli.scala @@ -195,7 +195,7 @@ object ScalafixRuleNames { CliRunner.fromOptions(options) match { case Ok(runner) => RunScalafix(runner) case NotOk(err) => - PrintAndExit(err.toString(), ExitStatus.InvalidCommandLineOption) + PrintAndExit(err.toString(), ExitStatus.CommandLineError) } def main(args: Array[String]): Unit = { val exit = runMain(args.to[Seq], CommonOptions()) @@ -214,7 +214,7 @@ object ScalafixRuleNames { import CliCommand._ OptionsParser.withHelp.detailedParse(args) match { case Left(err) => - PrintAndExit(err, ExitStatus.InvalidCommandLineOption) + PrintAndExit(err, ExitStatus.CommandLineError) case Right((WithHelp(_, help @ true, _), _, _)) => PrintAndExit(helpMessage, ExitStatus.Ok) case Right((WithHelp(usage @ true, _, _), _, _)) => @@ -260,7 +260,7 @@ object ScalafixRuleNames { // This one accummulates a lot of garbage, scalameta needs to get rid of it. PlatformTokenizerCache.megaCache.clear() if (commonOptions.cliArg.hasErrors) { - ExitStatus.merge(ExitStatus.InvalidCommandLineOption, result) + ExitStatus.merge(ExitStatus.CommandLineError, result) } else { result } diff --git a/scalafix-cli/src/main/scala/scalafix/cli/CliRunner.scala b/scalafix-cli/src/main/scala/scalafix/cli/CliRunner.scala index 998b67f01..cbf7a5d29 100644 --- a/scalafix-cli/src/main/scala/scalafix/cli/CliRunner.scala +++ b/scalafix-cli/src/main/scala/scalafix/cli/CliRunner.scala @@ -140,7 +140,7 @@ sealed abstract case class CliRunner( Patch.unifiedDiff(input.semanticFile.get, input.original) common.err.println(diff) } - ExitStatus.StaleSemanticDB + ExitStatus.StaleSemanticdbError } } @@ -183,7 +183,7 @@ sealed abstract case class CliRunner( ) ) common.out.println(diff) - ExitStatus.TestFailed + ExitStatus.TestError } case WriteMode.WriteFile => val (fixed, messages) = rule.applyAndLint(ctx) diff --git a/scalafix-cli/src/main/scala/scalafix/cli/ExitStatus.scala b/scalafix-cli/src/main/scala/scalafix/cli/ExitStatus.scala index e57df6432..fdab05644 100644 --- a/scalafix-cli/src/main/scala/scalafix/cli/ExitStatus.scala +++ b/scalafix-cli/src/main/scala/scalafix/cli/ExitStatus.scala @@ -27,10 +27,10 @@ object ExitStatus { UnexpectedError, ParseError, ScalafixError, - InvalidCommandLineOption, - MissingSemanticDB, - StaleSemanticDB, - TestFailed, + CommandLineError, + MissingSemanticdbError, + StaleSemanticdbError, + TestError, LinterError, NoFilesError : ExitStatus = generateExitStatus diff --git a/scalafix-cli/src/main/scala/scalafix/internal/cli/CliParser.scala b/scalafix-cli/src/main/scala/scalafix/internal/cli/CliParser.scala deleted file mode 100644 index acdd468d3..000000000 --- a/scalafix-cli/src/main/scala/scalafix/internal/cli/CliParser.scala +++ /dev/null @@ -1,84 +0,0 @@ -package scalafix.internal.cli - -import metaconfig._ -import metaconfig.generic.Setting -import metaconfig.generic.Settings -import metaconfig.Configured.ok -import metaconfig.internal.Case -import scala.annotation.StaticAnnotation - -object CliParser { - - class repeated extends StaticAnnotation - - def parseArgs[T](args: List[String])( - implicit settings: Settings[T]): Configured[Conf] = { - def loop( - curr: Conf.Obj, - xs: List[String], - s: State): Configured[Conf.Obj] = { - def add(key: String, value: Conf) = Conf.Obj((key, value) :: curr.values) - (xs, s) match { - case (Nil, NoFlag) => ok(curr) - case (Nil, Flag(flag, _)) => ok(add(flag, Conf.fromBoolean(true))) - case (head :: tail, NoFlag) => - if (head.startsWith("-")) { - val camel = Case.kebabToCamel(dash.replaceFirstIn(head, "")) - camel.split("\\.").toList match { - case Nil => - ConfError.message(s"Flag '$head' must not be empty").notOk - case flag :: flags => - settings.get(flag, flags) match { - case None => - settings.get(flag) match { - // TODO: upstream special handling for metaconfig.Conf fields. - case Some(setting) if setting.tpe == "metaconfig.Conf" => - loop(curr, tail, Flag(camel, setting)) - case _ => - ConfError - .invalidFields(camel :: Nil, settings.names) - .notOk - } - case Some(setting) => - if (setting.isBoolean) { - val newCurr = add(camel, Conf.fromBoolean(true)) - loop(newCurr, tail, NoFlag) - } else { - loop(curr, tail, Flag(camel, setting)) - } - } - } - } else { - ok(add("remainingArgs", Conf.fromList(xs.map(Conf.fromString)))) - } - case (head :: tail, Flag(flag, setting)) => - val value = Conf.fromNumberOrString(head) - val newCurr = - if (setting.isRepeated) { - curr.map.get(flag) match { - case Some(Conf.Lst(values)) => Conf.Lst(values :+ value) - case _ => Conf.Lst(value :: Nil) - } - } else { - value - } - loop(add(flag, newCurr), tail, NoFlag) - } - } - loop(Conf.Obj(), args, NoFlag).map(_.normalize) - } - - private sealed trait State - private case class Flag(flag: String, setting: Setting) extends State - private case object NoFlag extends State - private val dash = "--?".r - - implicit class XtensionSetting(setting: Setting) { - def isRepeated: Boolean = { - // see https://github.com/olafurpg/metaconfig/issues/50 - setting.tpe.contains("List[") || - setting.annotations.exists(_.isInstanceOf[repeated]) - } - } - -} diff --git a/scalafix-cli/src/main/scala/scalafix/internal/cli/ScalafixOptions.scala b/scalafix-cli/src/main/scala/scalafix/internal/cli/ScalafixOptions.scala index 6337bf8fa..f983c7347 100644 --- a/scalafix-cli/src/main/scala/scalafix/internal/cli/ScalafixOptions.scala +++ b/scalafix-cli/src/main/scala/scalafix/internal/cli/ScalafixOptions.scala @@ -186,7 +186,7 @@ case class ScalafixOptions( def parallel: Boolean = !noParallel def classpathRoots: List[AbsolutePath] = classpathAutoRoots.fold(List(common.workingPath))(cp => - Classpath(cp).shallow) + Classpath(cp).entries) def projectIdPrefix: String = projectId.fold("")(id => s"[$id] ") lazy val diagnostic: ScalafixReporter = ScalafixReporter.default.copy( diff --git a/scalafix-cli/src/main/scala/scalafix/v1/Args.scala b/scalafix-cli/src/main/scala/scalafix/v1/Args.scala index 2804b2a80..7756a481d 100644 --- a/scalafix-cli/src/main/scala/scalafix/v1/Args.scala +++ b/scalafix-cli/src/main/scala/scalafix/v1/Args.scala @@ -1,5 +1,6 @@ package scalafix.v1 +import scala.language.higherKinds import java.io.File import java.io.PrintStream import java.net.URI @@ -17,6 +18,8 @@ import metaconfig.annotation.ExtraName import metaconfig.generic.Surface import metaconfig.internal.ConfGet import metaconfig.typesafeconfig.typesafeConfigMetaconfigParser +import pprint.TPrint +import scala.annotation.StaticAnnotation import scala.meta.internal.io.PathIO import scala.meta.io.AbsolutePath import scala.meta.io.Classpath @@ -29,58 +32,106 @@ import scalafix.internal.reflect.ClasspathOps import scalafix.internal.util.SymbolTable import scalafix.internal.v1.Rules +class Section(val name: String) extends StaticAnnotation + case class Args( - cwd: AbsolutePath, - out: PrintStream, + @Section("Common options") + @Description( + "Scalafix rules to run, for example ExplicitResultTypes. " + + "The syntax for rules is documented in https://scalacenter.github.io/scalafix/docs/users/configuration#rules") @ExtraName("r") rules: List[String] = Nil, + @Description("Files or directories (recursively visited) to fix.") + @ExtraName("remainingArgs") + files: List[AbsolutePath] = Nil, + @Description( + "File path to a .scalafix.conf configuration file. " + + "Defaults to .scalafix.conf in the current working directory, if any.") config: Option[AbsolutePath] = None, - toolClasspath: List[AbsolutePath] = Nil, + @Description( + "Check that all files have been fixed with scalafix, exiting with non-zero code on violations. " + + "Won't write to files.") + test: Boolean = false, + @Description("Print fixed output to stdout instead of writing in-place.") + stdout: Boolean = false, + @Description( + "If set, only apply scalafix to added and edited files in git diff against the master branch.") + diff: Boolean = false, + @Description( + "If set, only apply scalafix to added and edited files in git diff against a provided branch, commit or tag.") + diffBase: Option[String] = None, + @Description("Print out additional diagnostics while running scalafix.") + verbose: Boolean = false, + @Description("Print out this help message and exit") + @ExtraName("h") + help: Boolean = false, + @Description("Print out version number and exit") + @ExtraName("v") + version: Boolean = false, + @Section("Semantic options") + @Description( + "Full classpath of the files to fix, required for semantic rules. " + + "The source files that should be fixed must be compiled with semanticdb-scalac. " + + "Dependencies are required by rules like ExplicitResultTypes, but the dependencies do not " + + "need to be compiled with semanticdb-scalac." + ) classpath: Classpath = Classpath(Nil), - ls: Ls = Ls.Find, + @Description("Absolute path passed to semanticdb with -P:semanticdb:sourceroot:. " + + "Relative filenames persisted in the Semantic DB are absolutized by the " + + "sourceroot. Defaults to current working directory if not provided.") sourceroot: Option[AbsolutePath] = None, - @ExtraName("remainingArgs") - files: List[AbsolutePath] = Nil, + @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, + @Description("Additional directories to scan for --auto-classpath") + @ExtraName("classpathAutoRoots") + autoClasspathRoots: List[AbsolutePath] = Nil, + @Section("Less common options") + @Description( + "Unix-style glob for files to exclude from fixing. " + + "The glob syntax is defined by `nio.FileSystem.getPathMatcher`.") exclude: List[PathMatcher] = Nil, - parser: MetaParser = MetaParser(), + @Description( + "Additional classpath for compiling and classloading custom rules.") + toolClasspath: Classpath = Classpath(Nil), + @Description("The encoding to use for reading/writing files") charset: Charset = StandardCharsets.UTF_8, - stdout: Boolean = false, - test: Boolean = false, + @Description("If set, throw exception in the end instead of System.exit") noSysExit: Boolean = false, - projectId: Option[String] = None, - metacpCacheDir: List[AbsolutePath] = Nil, - metacpParallel: Boolean = false, - noStrictSemanticdb: Boolean = false, - noParallel: Boolean = false, - autoClasspath: Boolean = false, + @Description("Don't error on stale semanticdb files.") + noStaleSemanticdb: Boolean = false, + @Description("Custom settings to override .scalafix.conf") settings: Conf = Conf.Obj.empty, + @Description( + "The format for console output, if sbt prepends [error] prefix") format: OutputFormat = OutputFormat.Default, + @Description( + "Regex that is passed as first argument to fileToFix.replaceAll(outFrom, outTo)") outFrom: Option[String] = None, + @Description( + "Replacement string that is passed as second argument to fileToFix.replaceAll(outFrom, outTo)") outTo: Option[String] = None, @Description( "Write to files. In case of linter error adds a comment to suppress the error.") autoSuppressLinterErrors: Boolean = false, - @Description( - "Automatically infer --classpath starting from these directories. " + - "Ignored if --classpath is provided.") - @ExtraName("classpathAutoRoots") - autoClasspathRoots: List[AbsolutePath] = Nil, - @Description( - "If set, only apply scalafix to added and edited files in git diff against master.") - diff: Boolean = false, - @Description( - "If set, only apply scalafix to added and edited files in git diff against a provided branch, commit or tag. (defaults to master)") - diffBase: Option[String] = None, - @Description("Don't use fancy progress bar.") - nonInteractive: Boolean = false, - verbose: Boolean = false + @Description("The current working directory") + cwd: AbsolutePath, + @Hidden + out: PrintStream, + @Hidden + ls: Ls = Ls.Find ) { def configuredSymtab: Configured[SymbolTable] = { ClasspathOps.newSymbolTable( classpath = classpath, cacheDirectory = metacpCacheDir.headOption, - parallel = metacpParallel, out = out ) match { case Some(symtab) => @@ -131,13 +182,10 @@ case class Args( val decoderSettings = RuleDecoder .Settings() .withConfig(scalafixConfig) - .withToolClasspath(toolClasspath) + .withToolClasspath(toolClasspath.entries) .withCwd(cwd) val decoder = RuleDecoder.decoder(decoderSettings) - decoder.read(rulesConf).andThen { rules => - if (rules.isEmpty) ConfError.message("No rules provided").notOk - else rules.withConfig(base) - } + decoder.read(rulesConf).andThen(_.withConfig(base)) } def resolvedPathReplace: Configured[AbsolutePath => AbsolutePath] = @@ -225,9 +273,8 @@ case class Args( object Args { val baseMatcher: PathMatcher = FileSystems.getDefault.getPathMatcher("glob:**.{scala,sbt}") - val default = new Args(PathIO.workingDirectory, System.out) + val default = new Args(cwd = PathIO.workingDirectory, out = System.out) - implicit val surface: Surface[Args] = generic.deriveSurface def decoder(cwd: AbsolutePath, out: PrintStream): ConfDecoder[Args] = { implicit val classpathDecoder: ConfDecoder[Classpath] = ConfDecoder.stringConfDecoder.map { cp => @@ -240,11 +287,10 @@ object Args { } implicit val absolutePathDecoder: ConfDecoder[AbsolutePath] = ConfDecoder.stringConfDecoder.map(AbsolutePath(_)(cwd)) - generic.deriveDecoder(Args(cwd, out)) + val base = new Args(cwd = cwd, out = out) + generic.deriveDecoder(base) } - implicit val confDecoder: ConfDecoder[Conf] = // TODO: upstream - ConfDecoder.instanceF[Conf](c => Configured.ok(c)) implicit val charsetDecoder: ConfDecoder[Charset] = ConfDecoder.stringConfDecoder.map(name => Charset.forName(name)) implicit val printStreamDecoder: ConfDecoder[PrintStream] = @@ -252,6 +298,41 @@ object Args { implicit val pathMatcherDecoder: ConfDecoder[PathMatcher] = ConfDecoder.stringConfDecoder.map(glob => FileSystems.getDefault.getPathMatcher("glob:" + glob)) + + implicit val confEncoder: ConfEncoder[Conf] = + ConfEncoder.ConfEncoder + implicit val pathEncoder: ConfEncoder[AbsolutePath] = + ConfEncoder.StringEncoder.contramap(_.toString()) + implicit val classpathEncoder: ConfEncoder[Classpath] = + 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 argsEncoder: ConfEncoder[Args] = generic.deriveEncoder + implicit val absolutePathPrint: TPrint[AbsolutePath] = + TPrint.make[AbsolutePath](_ => "") + implicit val pathMatcherPrint: TPrint[PathMatcher] = + 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 => + ev.render + } + implicit def iterablePrint[C[x] <: Iterable[x], T]( + implicit ev: pprint.TPrint[T]): TPrint[C[T]] = + TPrint.make { implicit cfg => + s"[${ev.render} ...]" + } + implicit val argsSurface: Surface[Args] = generic.deriveSurface } case class ScalafixFileConfig(rules: Conf, other: Conf) diff --git a/scalafix-cli/src/main/scala/scalafix/v1/Hidden.scala b/scalafix-cli/src/main/scala/scalafix/v1/Hidden.scala new file mode 100644 index 000000000..aaffec040 --- /dev/null +++ b/scalafix-cli/src/main/scala/scalafix/v1/Hidden.scala @@ -0,0 +1,6 @@ +package scalafix.v1 + +import scala.annotation.StaticAnnotation + +// TODO: contribute upstream to metaconfig +class Hidden extends StaticAnnotation diff --git a/scalafix-cli/src/main/scala/scalafix/v1/Ls.scala b/scalafix-cli/src/main/scala/scalafix/v1/Ls.scala index 261c86534..784521d02 100644 --- a/scalafix-cli/src/main/scala/scalafix/v1/Ls.scala +++ b/scalafix-cli/src/main/scala/scalafix/v1/Ls.scala @@ -1,6 +1,7 @@ package scalafix.v1 import metaconfig.ConfDecoder +import metaconfig.ConfEncoder import scalafix.internal.config.ReaderUtil sealed abstract class Ls @@ -9,5 +10,7 @@ object Ls { case object Find extends Ls // TODO: git ls-files + implicit val encoder: ConfEncoder[Ls] = + ConfEncoder.StringEncoder.contramap(_.toString.toLowerCase()) implicit val decoder: ConfDecoder[Ls] = ReaderUtil.oneOf[Ls](Find) } diff --git a/scalafix-cli/src/main/scala/scalafix/v1/Main.scala b/scalafix-cli/src/main/scala/scalafix/v1/Main.scala index 9993ff34a..b8c4697a2 100644 --- a/scalafix-cli/src/main/scala/scalafix/v1/Main.scala +++ b/scalafix-cli/src/main/scala/scalafix/v1/Main.scala @@ -6,17 +6,25 @@ import java.nio.file.Files import java.nio.file.Path import java.nio.file.SimpleFileVisitor import java.nio.file.attribute.BasicFileAttributes +import metaconfig.Conf +import metaconfig.ConfEncoder import metaconfig.Configured +import metaconfig.annotation.Inline +import metaconfig.generic.Setting +import metaconfig.generic.Settings +import metaconfig.internal.Case import scala.meta.io.AbsolutePath import scala.collection.mutable.ArrayBuffer import scala.meta.parsers.Parsed import scala.util.control.NoStackTrace import scala.util.control.NonFatal +import scalafix.Versions import scalafix.cli.ExitStatus -import scalafix.internal.cli.CliParser import scalafix.internal.cli.WriteMode import scalafix.internal.config.ScalafixReporter import scalafix.lint.LintMessage +import org.typelevel.paiges.{Doc => D} +import scalafix.diff.DiffUtils object Main { @@ -51,9 +59,12 @@ object Main { buf.result() } - class NonZeroExitCode(code: ExitStatus) + final class NonZeroExitCode(code: ExitStatus) extends Exception(code.toString) with NoStackTrace + final class StaleSemanticDB(val path: AbsolutePath, val diff: String) + extends Exception(s"Stale SemanticDB\n$diff") + with NoStackTrace def main(args: Array[String]): Unit = { val exit = run(args, AbsolutePath.workingDirectory.toNIO, System.out) @@ -119,7 +130,21 @@ object Main { args.classpath, args.symtab ) - args.rules.semanticPatch(sdoc, args.args.autoSuppressLinterErrors) + val (fix, messages) = + args.rules.semanticPatch(sdoc, args.args.autoSuppressLinterErrors) + val isStaleSemanticDB = input.text != sdoc.sdoc.text + val fixDoesNotMatchInput = input.text != fix + if (isStaleSemanticDB && fixDoesNotMatchInput) { + val diff = DiffUtils.unifiedDiff( + file.toString() + "-ondisk", + file.toString() + "-semanticdb", + input.text.lines.toList, + sdoc.sdoc.text.lines.toList, + 3 + ) + throw new StaleSemanticDB(file, diff) + } + (fix, messages) } else { args.rules.syntacticPatch(doc, args.args.autoSuppressLinterErrors) } @@ -130,7 +155,7 @@ object Main { args.mode match { case WriteMode.Test => if (fixed == input.text) ExitStatus.Ok - else ExitStatus.TestFailed + else ExitStatus.TestError case WriteMode.Stdout => args.args.out.println(fixed) ExitStatus.Ok @@ -150,7 +175,13 @@ object Main { catch { case e: SemanticDoc.Error.MissingSemanticdb => args.config.reporter.error(e.getMessage) - ExitStatus.MissingSemanticDB + ExitStatus.MissingSemanticdbError + case e: StaleSemanticDB => + if (args.args.noStaleSemanticdb) ExitStatus.Ok + else { + args.config.reporter.error(e.getMessage) + ExitStatus.StaleSemanticdbError + } case NonFatal(e) => handleException(e, args.args.out) ExitStatus.UnexpectedError @@ -167,17 +198,98 @@ object Main { adjustExitCode(args, exit, files) } - def run(args: Seq[String], cwd: Path, out: PrintStream): ExitStatus = - CliParser - .parseArgs[Args](args.toList) - .andThen { c => - c.as[Args](Args.decoder(AbsolutePath(cwd), out)) + def version = + s"Scalafix ${Versions.version}" + def usage = + """|Usage: scalafix [options] [ ...] + |""".stripMargin + def description = + D.paragraph( + """|Scalafix is a refactoring and linting tool. + |Scalafix supports both syntactic and semantic linter and rewrite rules. + |Syntactic rules can run on source code without compilation. + |Semantic rules can run on source code that has been compiled with the + |SemanticDB compiler plugin. + |""".stripMargin + ) + + def options(width: Int): String = { + val sb = new StringBuilder() + val settings = Settings[Args] + val default = ConfEncoder[Args].writeObj(Args.default) + def printOption(setting: Setting, value: Conf): Unit = { + if (setting.annotations.exists(_.isInstanceOf[Hidden])) return + setting.annotations.foreach { + case section: Section => + sb.append("\n") + .append(section.name) + .append(":\n") + case _ => } + val name = Case.camelToKebab(setting.name) + sb.append("\n") + .append(" --") + .append(name) + setting.extraNames.foreach { name => + if (name.length == 1) { + sb.append(" | -") + .append(Case.camelToKebab(name)) + } + } + if (!setting.isBoolean) { + sb.append(" ") + .append(setting.tpe) + .append(" (default: ") + .append(value.toString()) + .append(")") + } + sb.append("\n") + setting.description.foreach { description => + sb.append(" ") + .append(D.paragraph(description).nested(4).render(width)) + .append('\n') + } + } + + settings.settings.zip(default.values).foreach { + case (setting, (_, value)) => + if (setting.annotations.exists(_.isInstanceOf[Inline])) { + for { + underlying <- setting.underlying.toList + (field, (_, fieldDefault)) <- underlying.settings.zip( + value.asInstanceOf[Conf.Obj].values) + } { + printOption(field, fieldDefault) + } + } else { + printOption(setting, value) + } + } + sb.toString() + } + + def helpMessage(out: PrintStream, width: Int): Unit = { + out.println(version) + out.println(usage) + out.println(description.render(width)) + out.println(options(width)) + } + + def run(args: Seq[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.rules.isEmpty) { + if (validated.args.help) { + helpMessage(out, 80) + ExitStatus.Ok + } else if (validated.args.version) { + out.println(Versions.version) + ExitStatus.Ok + } else if (validated.rules.isEmpty) { out.println("Missing --rules") - ExitStatus.InvalidCommandLineOption + ExitStatus.CommandLineError } else { val adjusted = validated.copy( args = validated.args.copy( @@ -189,6 +301,6 @@ object Main { } case Configured.NotOk(err) => ScalafixReporter.default.copy(outStream = out).error(err.toString()) - ExitStatus.InvalidCommandLineOption + ExitStatus.CommandLineError } } diff --git a/scalafix-cli/src/main/scala/scalafix/v1/ValidatedArgs.scala b/scalafix-cli/src/main/scala/scalafix/v1/ValidatedArgs.scala index cf80ae1e6..d60dae427 100644 --- a/scalafix-cli/src/main/scala/scalafix/v1/ValidatedArgs.scala +++ b/scalafix-cli/src/main/scala/scalafix/v1/ValidatedArgs.scala @@ -37,7 +37,7 @@ case class ValidatedArgs( def parse(input: Input): Parsed[Source] = { import scala.meta._ - val dialect = parser.dialectForFile(input.syntax) + val dialect = config.parser.dialectForFile(input.syntax) dialect(input).parse[Source] } diff --git a/scalafix-core/shared/src/main/scala/scalafix/internal/config/OutputFormat.scala b/scalafix-core/shared/src/main/scala/scalafix/internal/config/OutputFormat.scala index 803d46de7..d171dad3e 100644 --- a/scalafix-core/shared/src/main/scala/scalafix/internal/config/OutputFormat.scala +++ b/scalafix-core/shared/src/main/scala/scalafix/internal/config/OutputFormat.scala @@ -1,7 +1,7 @@ package scalafix.internal.config +import metaconfig.ConfEncoder import metaconfig.{Conf, ConfDecoder, Configured} - import scalafix.internal.config.MetaconfigPendingUpstream._ sealed abstract class OutputFormat @@ -19,6 +19,8 @@ object 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-cli/src/main/scala/scalafix/v1/MetaParser.scala b/scalafix-core/shared/src/main/scala/scalafix/internal/config/ParserConfig.scala similarity index 73% rename from scalafix-cli/src/main/scala/scalafix/v1/MetaParser.scala rename to scalafix-core/shared/src/main/scala/scalafix/internal/config/ParserConfig.scala index 0450261de..7d684f853 100644 --- a/scalafix-cli/src/main/scala/scalafix/v1/MetaParser.scala +++ b/scalafix-core/shared/src/main/scala/scalafix/internal/config/ParserConfig.scala @@ -1,11 +1,11 @@ -package scalafix.v1 +package scalafix.internal.config import java.nio.file.FileSystems import java.nio.file.PathMatcher import metaconfig._ import scala.meta.Dialect -case class MetaParser( +case class ParserConfig( trailingCommas: Boolean = true, inlineKeyword: Boolean = false ) { @@ -22,11 +22,11 @@ case class MetaParser( } -object MetaParser { +object ParserConfig { val sbtMatcher: PathMatcher = FileSystems.getDefault.getPathMatcher("glob:*.sbt") - implicit val surface: generic.Surface[MetaParser] = + implicit val surface: generic.Surface[ParserConfig] = generic.deriveSurface - implicit val decoder: ConfDecoder[MetaParser] = - generic.deriveDecoder(MetaParser()) + implicit val codec: ConfCodec[ParserConfig] = + generic.deriveCodec(ParserConfig()) } diff --git a/scalafix-core/shared/src/main/scala/scalafix/internal/config/ScalafixConfig.scala b/scalafix-core/shared/src/main/scala/scalafix/internal/config/ScalafixConfig.scala index b9663f089..470cdf7ba 100644 --- a/scalafix-core/shared/src/main/scala/scalafix/internal/config/ScalafixConfig.scala +++ b/scalafix-core/shared/src/main/scala/scalafix/internal/config/ScalafixConfig.scala @@ -5,13 +5,12 @@ import java.io.OutputStream import java.io.PrintStream import scala.meta._ import scala.meta.dialects.Scala212 -import scala.meta.parsers.Parse import metaconfig._ import metaconfig.Input import metaconfig.generic.Surface case class ScalafixConfig( - parser: Parse[_ <: Tree] = Parse.parseSource, + parser: ParserConfig = ParserConfig(), debug: DebugConfig = DebugConfig(), groupImportsByPrefix: Boolean = true, fatalWarnings: Boolean = true, 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 fb72e7d8a..161d4d743 100644 --- a/scalafix-reflect/src/main/scala/scalafix/internal/reflect/ClasspathOps.scala +++ b/scalafix-reflect/src/main/scala/scalafix/internal/reflect/ClasspathOps.scala @@ -23,7 +23,7 @@ object ClasspathOps { def toMetaClasspath( sclasspath: Classpath, cacheDirectory: Option[AbsolutePath] = None, - parallel: Boolean = true, + parallel: Boolean = false, out: PrintStream = devNull): Option[Classpath] = { val (processed, toProcess) = sclasspath.entries.partition { path => path.isDirectory && @@ -48,7 +48,7 @@ object ClasspathOps { def newSymbolTable( classpath: Classpath, cacheDirectory: Option[AbsolutePath] = None, - parallel: Boolean = true, + parallel: Boolean = false, out: PrintStream = System.out): Option[SymbolTable] = { toMetaClasspath(classpath, cacheDirectory, parallel, out) .map(new LazySymbolTable(_)) diff --git a/scalafix-sbt/src/main/scala/scalafix/sbt/ScalafixPlugin.scala b/scalafix-sbt/src/main/scala/scalafix/sbt/ScalafixPlugin.scala index 4015ac8f3..9aeecd376 100644 --- a/scalafix-sbt/src/main/scala/scalafix/sbt/ScalafixPlugin.scala +++ b/scalafix-sbt/src/main/scala/scalafix/sbt/ScalafixPlugin.scala @@ -150,12 +150,6 @@ object ScalafixPlugin extends AutoPlugin { val baseDir = baseDirectory.in(ThisBuild).value val sbtDir: File = baseDir./("project") val sbtFiles = baseDir.*("*.sbt").get - val options = - "--no-strict-semanticdb" :: - "--classpath-auto-roots" :: - baseDir./("target").getAbsolutePath :: - sbtDir.getAbsolutePath :: - Nil ++ extraOptions scalafixTaskImpl( scalafixParserCompat.parsed, compat, @@ -284,16 +278,7 @@ object ScalafixPlugin extends AutoPlugin { streams.log.info(s"Running scalafix on ${files.size} Scala sources") } - args += ( - "--project-id", - projectId, - "--no-sys-exit", - "--non-interactive" - ) - - if (!scalafixParallel.value) { - args += "--no-parallel" - } + args += "--no-sys-exit" args ++= files.iterator.map(_.getAbsolutePath) 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 27d78b086..3724d59c1 100644 --- a/scalafix-tests/unit/src/main/scala/scalafix/test/StringFS.scala +++ b/scalafix-tests/unit/src/main/scala/scalafix/test/StringFS.scala @@ -12,16 +12,18 @@ object StringFS { */ def string2dir(layout: String): AbsolutePath = { val root = Files.createTempDirectory("root") - layout.split("(?=\n/)").foreach { row => - row.stripPrefix("\n").split("\n", 2).toList match { - case path :: contents :: Nil => - val file = root.resolve(path.stripPrefix("/")) - file.getParent.toFile.mkdirs() - Files.write(file, contents.getBytes) - case els => - throw new IllegalArgumentException( - s"Unable to split argument info path/contents! \n$els") + if (!layout.isEmpty) { + layout.split("(?=\n/)").foreach { row => + row.stripPrefix("\n").split("\n", 2).toList match { + case path :: contents :: Nil => + val file = root.resolve(path.stripPrefix("/")) + file.getParent.toFile.mkdirs() + Files.write(file, contents.getBytes) + case els => + throw new IllegalArgumentException( + s"Unable to split argument info path/contents! \n$els") + } } } AbsolutePath(root) diff --git a/scalafix-tests/unit/src/test/scala/scalafix/tests/cli/BaseCliTest.scala b/scalafix-tests/unit/src/test/scala/scalafix/tests/cli/BaseCliTest.scala index da39b613f..5520298c2 100644 --- a/scalafix-tests/unit/src/test/scala/scalafix/tests/cli/BaseCliTest.scala +++ b/scalafix-tests/unit/src/test/scala/scalafix/tests/cli/BaseCliTest.scala @@ -130,6 +130,13 @@ trait BaseCliTest extends FunSuite with DiffAssertions { ) } + def slurp(path: AbsolutePath): String = + FileIO.slurp(path, StandardCharsets.UTF_8) + def slurpInput(path: RelativePath): String = + slurp(AbsolutePath(BuildInfo.inputSourceroot.toPath).resolve(path)) + def slurpOutput(path: RelativePath): String = + slurp(AbsolutePath(BuildInfo.outputSourceroot.toPath).resolve(path)) + case class Result( exit: ExitStatus, original: String, @@ -139,6 +146,7 @@ trait BaseCliTest extends FunSuite with DiffAssertions { name: String, args: Seq[String], expectedExit: ExitStatus, + preprocess: AbsolutePath => Unit = _ => (), outputAssert: String => Unit = _ => (), rule: String = RemoveUnusedImports.toString(), path: RelativePath = removeImportsPath, @@ -158,6 +166,7 @@ trait BaseCliTest extends FunSuite with DiffAssertions { ops.cp(ops.Path(BuildInfo.inputSourceroot.toPath), root) val rootNIO = root.toNIO writeTestkitConfiguration(rootNIO, rootNIO.resolve(path.toNIO)) + preprocess(AbsolutePath(rootNIO)) val exit = Main.run( args ++ Seq( "-r", @@ -167,25 +176,22 @@ trait BaseCliTest extends FunSuite with DiffAssertions { root.toNIO, new PrintStream(out) ) - val original = FileIO.slurp( - AbsolutePath(BuildInfo.inputSourceroot).resolve(path), - StandardCharsets.UTF_8) + val original = slurpInput(path) val obtained = { val fixed = FileIO.slurp( AbsolutePath(root.toNIO).resolve(path), StandardCharsets.UTF_8) - if (fileIsFixed) SemanticRuleSuite.stripTestkitComments(fixed) - else fixed - } - val expected = - if (fileIsFixed) { - FileIO.slurp( - AbsolutePath(BuildInfo.outputSourceroot).resolve(path), - StandardCharsets.UTF_8) + if (fileIsFixed && fixed.startsWith("/*")) { + SemanticRuleSuite.stripTestkitComments(fixed) } else { - original + fixed } + + } + val expected = + if (fileIsFixed) slurpOutput(path) + else original val output = fansi.Str(out.toString()).plainText assert(exit == expectedExit, output) outputAssert(output) diff --git a/scalafix-tests/unit/src/test/scala/scalafix/tests/cli/CliArgsTest.scala b/scalafix-tests/unit/src/test/scala/scalafix/tests/cli/CliArgsTest.scala deleted file mode 100644 index 3a736142f..000000000 --- a/scalafix-tests/unit/src/test/scala/scalafix/tests/cli/CliArgsTest.scala +++ /dev/null @@ -1,56 +0,0 @@ -package scalafix.tests.cli - -import scala.collection.immutable.Seq -import scalafix.cli.CliCommand.PrintAndExit -import scalafix.cli.CliCommand.RunScalafix -import scalafix.internal.rule.ProcedureSyntax - -class CliArgsTest extends BaseCliTest { - test("--zsh") { - val obtained = parse(Seq("--zsh")) - assert(obtained.isOk) - assert(obtained.isInstanceOf[PrintAndExit]) - } - - test("--bash") { - val obtained = parse(Seq("--bash")) - assert(obtained.isOk) - assert(obtained.isInstanceOf[PrintAndExit]) - } - - test("--rules") { - val RunScalafix(runner) = - parse(Seq("--rules", "DottyVolatileLazyVal")) - assert(runner.rule.name.value == "DottyVolatileLazyVal") - assert(parse(Seq("--rules", "Foobar")).isError) - } - - test("parse") { - val RunScalafix(runner) = parse( - Seq( - "--verbose", - "--config-str", - "fatalWarnings=true", - "--single-thread", - "-r", - ProcedureSyntax.toString, - "--files", - "build.sbt", - "project/Mima.scala", - "--stdout", - "project/Dependencies.scala" - )) - val obtained = runner.cli - assert(!runner.writeMode.isWriteFile) - assert(runner.config.fatalWarnings) - assert(obtained.verbose) - assert(obtained.noParallel) - assert( - obtained.files == List( - "build.sbt", - "project/Mima.scala", - "project/Dependencies.scala" - ) - ) - } -} diff --git a/scalafix-tests/unit/src/test/scala/scalafix/tests/cli/CliGitDiffTests.scala b/scalafix-tests/unit/src/test/scala/scalafix/tests/cli/CliGitDiffTests.scala index 71c517d11..007fe1dd6 100644 --- a/scalafix-tests/unit/src/test/scala/scalafix/tests/cli/CliGitDiffTests.scala +++ b/scalafix-tests/unit/src/test/scala/scalafix/tests/cli/CliGitDiffTests.scala @@ -134,7 +134,7 @@ class CliGitDiffTests() extends FunSuite with DiffAssertions { val fs = new Fs() addConf(fs) val cli = new Cli(fs.workingDirectory) - val obtained = runDiff(cli, ExitStatus.InvalidCommandLineOption) + val obtained = runDiff(cli, ExitStatus.CommandLineError) val expected = s"error: ${fs.workingDirectory} is not a git repository" assert(obtained.startsWith(expected)) @@ -201,21 +201,15 @@ class CliGitDiffTests() extends FunSuite with DiffAssertions { git.commit() val nonExistingHash = "a777777777777777777777777777777777777777" - val obtained = runDiff( - cli, - ExitStatus.InvalidCommandLineOption, - "--diff-base", - nonExistingHash) + val obtained = + runDiff(cli, ExitStatus.CommandLineError, "--diff-base", nonExistingHash) val expected = s"error: '$nonExistingHash' unknown revision or path not in the working tree." assert(obtained.startsWith(expected)) val wrongHashFormat = "a777" - val obtained2 = runDiff( - cli, - ExitStatus.InvalidCommandLineOption, - "--diff-base", - wrongHashFormat) + val obtained2 = + runDiff(cli, ExitStatus.CommandLineError, "--diff-base", wrongHashFormat) val expected2 = s"error: '$wrongHashFormat' unknown revision or path not in the working tree." assert(obtained2.startsWith(expected2)) diff --git a/scalafix-tests/unit/src/test/scala/scalafix/tests/cli/CliSemanticTests.scala b/scalafix-tests/unit/src/test/scala/scalafix/tests/cli/CliSemanticTests.scala index fc1c8a7e2..38fcfbe81 100644 --- a/scalafix-tests/unit/src/test/scala/scalafix/tests/cli/CliSemanticTests.scala +++ b/scalafix-tests/unit/src/test/scala/scalafix/tests/cli/CliSemanticTests.scala @@ -1,7 +1,10 @@ package scalafix.tests.cli +import java.nio.charset.StandardCharsets +import java.nio.file.Files import scala.meta.internal.io.PathIO import scala.collection.immutable.Seq +import scala.meta.internal.io.FileIO import scalafix.cli._ import scalafix.internal.rule.ExplicitResultTypes import scalafix.tests.rule.SemanticTests @@ -35,20 +38,63 @@ class CliSemanticTests extends BaseCliTest { "--classpath", semanticClasspath ), - expectedExit = ExitStatus.InvalidCommandLineOption, - outputAssert = msg => { - assert(msg.contains("--sourceroot")) - assert(msg.contains("bogus")) + expectedExit = ExitStatus.CommandLineError, + outputAssert = { out => + assert(out.contains("--sourceroot")) + assert(out.contains("bogus")) } ) checkSemantic( name = "MissingSemanticDB", args = Nil, // no --classpath - expectedExit = ExitStatus.MissingSemanticDB, - outputAssert = msg => { - assert(msg.contains("No SemanticDB associated with")) - assert(msg.contains(removeImportsPath.toNIO.getFileName.toString)) + expectedExit = ExitStatus.MissingSemanticdbError, + outputAssert = { out => + assert(out.contains("No SemanticDB associated with")) + assert(out.contains(removeImportsPath.toNIO.getFileName.toString)) + } + ) + + checkSemantic( + name = "StaleSemanticDB", + args = Seq( + "--classpath", + SemanticTests.defaultClasspath.syntax + ), + preprocess = { root => + val path = root.resolve(explicitResultTypesPath) + val code = FileIO.slurp(path, StandardCharsets.UTF_8) + val staleCode = code + "\n// comment\n" + Files.write(path.toNIO, staleCode.getBytes(StandardCharsets.UTF_8)) + }, + expectedExit = ExitStatus.StaleSemanticdbError, + rule = ExplicitResultTypes.toString(), + path = explicitResultTypesPath, + files = explicitResultTypesPath.toString(), + outputAssert = { out => + assert(out.contains("Stale SemanticDB")) + assert(out.contains(explicitResultTypesPath.toString + "-ondisk")) + assert(out.contains("-// comment")) + } + ) + + checkSemantic( + name = "StaleSemanticDB fix matches input", + args = Seq( + "--classpath", + SemanticTests.defaultClasspath.syntax + ), + preprocess = { root => + val expectedOutput = slurpOutput(explicitResultTypesPath) + val path = root.resolve(explicitResultTypesPath) + Files.write(path.toNIO, expectedOutput.getBytes(StandardCharsets.UTF_8)) + }, + expectedExit = ExitStatus.Ok, + rule = ExplicitResultTypes.toString(), + path = explicitResultTypesPath, + files = explicitResultTypesPath.toString(), + outputAssert = { out => + assert(out.isEmpty) } ) diff --git a/scalafix-tests/unit/src/test/scala/scalafix/tests/cli/CliSyntacticTests.scala b/scalafix-tests/unit/src/test/scala/scalafix/tests/cli/CliSyntacticTests.scala index f58a5e1d1..75f469f79 100644 --- a/scalafix-tests/unit/src/test/scala/scalafix/tests/cli/CliSyntacticTests.scala +++ b/scalafix-tests/unit/src/test/scala/scalafix/tests/cli/CliSyntacticTests.scala @@ -6,6 +6,19 @@ import scalafix.internal.rule._ class CliSyntacticTests extends BaseCliTest { + check( + name = "--help", + originalLayout = "", + args = Seq("--help"), + expectedLayout = "", + expectedExit = ExitStatus.Ok, + outputAssert = { out => + println(out) + assert(out.startsWith("Scalafix")) + assert(out.contains("--rules")) + } + ) + check( name = "fix file", originalLayout = s"""/hello.scala @@ -41,7 +54,7 @@ class CliSyntacticTests extends BaseCliTest { originalLayout = s"/foobar.scala\n", args = Seq("unknown-file.scala"), expectedLayout = "/foobar.scala", - expectedExit = ExitStatus.InvalidCommandLineOption + expectedExit = ExitStatus.CommandLineError ) check( @@ -49,7 +62,7 @@ class CliSyntacticTests extends BaseCliTest { originalLayout = s"/foobar.scala\n", args = Seq("foobar.scala"), expectedLayout = "/foobar.scala", - expectedExit = ExitStatus.InvalidCommandLineOption + expectedExit = ExitStatus.CommandLineError ) check( @@ -59,7 +72,7 @@ class CliSyntacticTests extends BaseCliTest { args = Seq("--test", "-r", ProcedureSyntax.toString, "foobar.scala"), expectedLayout = s"""/foobar.scala |$original""".stripMargin, - expectedExit = ExitStatus.TestFailed + expectedExit = ExitStatus.TestError ) check(