diff --git a/metals/src/main/scala/scala/meta/internal/metals/Directories.scala b/metals/src/main/scala/scala/meta/internal/metals/Directories.scala index 1af941f5424..fe4dfcb6550 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/Directories.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/Directories.scala @@ -4,6 +4,8 @@ import scala.meta.io.RelativePath object Directories { def database: RelativePath = RelativePath(".metals").resolve("metals.h2.db") + def hiddenScalafmt: RelativePath = + RelativePath(".metals").resolve(".scalafmt.conf") def readonly: RelativePath = RelativePath(".metals").resolve("readonly") def tmp: RelativePath = diff --git a/metals/src/main/scala/scala/meta/internal/metals/FormattingProvider.scala b/metals/src/main/scala/scala/meta/internal/metals/FormattingProvider.scala index 20a9d9d970b..fd95b12c8ec 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/FormattingProvider.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/FormattingProvider.scala @@ -28,6 +28,7 @@ import scala.meta.internal.metals.MetalsEnrichments._ import scala.meta.internal.metals.clients.language.MetalsLanguageClient import scala.meta.internal.semver.SemVer import scala.meta.io.AbsolutePath +import scala.meta.io.RelativePath import ch.epfl.scala.bsp4j.BuildTargetIdentifier import org.eclipse.lsp4j.jsonrpc.CancelChecker @@ -68,6 +69,7 @@ final class FormattingProvider( ) } + private val defaultScalafmtLocation = RelativePath(".scalafmt.conf") private var scalafmt = FormattingProvider.newScalafmt() private val reporterPromise = new AtomicReference[Option[Promise[Boolean]]](None) @@ -82,19 +84,21 @@ final class FormattingProvider( // Warms up the Scalafmt instance so that the first formatting request responds faster. // Does nothing if there is no .scalafmt.conf or there is no configured version setting. def load(): Unit = { - if (scalafmtConf(workspace).isFile && !Testing.isEnabled) { - try { - scalafmt.format( - scalafmtConf(workspace).toNIO, - Paths.get("Main.scala"), - "object Main {}", - ) - } catch { - case e: ScalafmtDynamicError => - scribe.debug( - s"Scalafmt issue while warming up due to config issue: ${e.getMessage()}" + scalafmtConf(workspace) match { + case Some(conf) if !Testing.isEnabled => + try { + scalafmt.format( + conf.toNIO, + Paths.get("Main.scala"), + "object Main {}", ) - } + } catch { + case e: ScalafmtDynamicError => + scribe.debug( + s"Scalafmt issue while warming up due to config issue: ${e.getMessage()}" + ) + } + case _ => } } @@ -104,47 +108,48 @@ final class FormattingProvider( projectRoot: AbsolutePath, token: CancelChecker, ): Future[util.List[l.TextEdit]] = { - scalafmt = scalafmt.withReporter(activeReporter) + scalafmt = scalafmt.withReporter(activeReporter(projectRoot)) reset(token) val input = path.toInputFromBuffers(buffers) - if (!scalafmtConf(projectRoot).isFile) { - handleMissingFile(scalafmtConf(projectRoot)).map { - case true => - runFormat(path, projectRoot, input).asJava - case false => - Collections.emptyList[l.TextEdit]() - } - } else { - val result = runFormat(path, projectRoot, input) - if (token.isCancelled) { - statusBar.addMessage( - s"${icons.info}Scalafmt cancelled by editor, try saving file again" - ) - } - reporterPromise.get() match { - case Some(promise) => - // Wait until "update .scalafmt.conf" dialogue has completed - // before returning future. - promise.future.map { - case true if !token.isCancelled => - runFormat(path, projectRoot, input).asJava - case _ => result.asJava - } - case None => - Future.successful(result.asJava) - } + scalafmtConf(projectRoot) match { + case None => + handleMissingFile(projectRoot).map { + case Some(conf) => + runFormat(path, conf, input).asJava + case None => + Collections.emptyList[l.TextEdit]() + } + case Some(config) => + val result = runFormat(path, config, input) + if (token.isCancelled) { + statusBar.addMessage( + s"${icons.info}Scalafmt cancelled by editor, try saving file again" + ) + } + reporterPromise.get() match { + case Some(promise) => + // Wait until "update .scalafmt.conf" dialogue has completed + // before returning future. + promise.future.map { + case true if !token.isCancelled => + runFormat(path, config, input).asJava + case _ => result.asJava + } + case None => + Future.successful(result.asJava) + } } } private def runFormat( path: AbsolutePath, - projectRoot: AbsolutePath, + scalafmtConf: AbsolutePath, input: Input, ): List[l.TextEdit] = { val fullDocumentRange = Position.Range(input, 0, input.chars.length).toLsp val formatted = try { - scalafmt.format(scalafmtConf(projectRoot).toNIO, path.toNIO, input.text) + scalafmt.format(scalafmtConf.toNIO, path.toNIO, input.text) } catch { case e: ScalafmtDynamicError => scribe.debug( @@ -200,37 +205,38 @@ final class FormattingProvider( tables.dismissedNotifications.ChangeScalafmtVersion .dismiss(24, TimeUnit.HOURS) None - } else if (item == Messages.dontShowAgain) { - tables.dismissedNotifications.ChangeScalafmtVersion - .dismissForever() - None } else None } } } else Future.successful(None) } - private def handleMissingFile(path: AbsolutePath): Future[Boolean] = { + private def handleMissingFile( + projectRoot: AbsolutePath + ): Future[Option[AbsolutePath]] = { if (!tables.dismissedNotifications.CreateScalafmtFile.isDismissed) { val params = MissingScalafmtConf.params() client.showMessageRequest(params).asScala.map { item => - if (item == MissingScalafmtConf.createFile) { - Files.createDirectories(path.toNIO.getParent) - Files - .write(path.toNIO, initialConfig().getBytes(StandardCharsets.UTF_8)) - client.showMessage(MissingScalafmtConf.fixedParams(isCancelled)) - true + if ( + item == MissingScalafmtConf.createFile || item == MissingScalafmtConf.runDefaults + ) { + val relative = + if (item == MissingScalafmtConf.createFile) defaultScalafmtLocation + else Directories.hiddenScalafmt + val toWrite = projectRoot.resolve(relative) + Files.write( + toWrite.toNIO, + initialConfig().getBytes(StandardCharsets.UTF_8), + ) + client.showMessage(MissingScalafmtConf.fixedParams(relative)) + Some(toWrite) } else if (item == Messages.notNow) { tables.dismissedNotifications.CreateScalafmtFile .dismiss(24, TimeUnit.HOURS) - false - } else if (item == Messages.dontShowAgain) { - tables.dismissedNotifications.CreateScalafmtFile - .dismissForever() - false - } else false + None + } else None } - } else Future.successful(false) + } else Future.successful(None) } private def initialConfig(): String = { @@ -359,15 +365,14 @@ final class FormattingProvider( private def checkIfDialectUpgradeRequired( config: ScalafmtConfig, - projectRoot: AbsolutePath, + configPath: AbsolutePath, ): Future[Unit] = { if (tables.dismissedNotifications.UpdateScalafmtConf.isDismissed) Future.unit else { Future(inspectDialectRewrite(config)).flatMap { case Some(rewrite) => - val canUpdate = - rewrite.canUpdate && scalafmtConf(projectRoot).isInside(projectRoot) + val canUpdate = rewrite.canUpdate && configPath.isInside(workspace) val params = UpdateScalafmtConf.params(rewrite.maxDialect, canUpdate) @@ -378,11 +383,10 @@ final class FormattingProvider( client.showMessageRequest(params).asScala.map { item => if (item == UpdateScalafmtConf.letUpdate) { - val text = - scalafmtConf(projectRoot).toInputFromBuffers(buffers).text + val text = configPath.toInputFromBuffers(buffers).text val updatedText = rewrite.rewrite(text) Files.write( - scalafmtConf(projectRoot).toNIO, + configPath.toNIO, updatedText.getBytes(StandardCharsets.UTF_8), ) } else if (item == Messages.notNow) { @@ -398,101 +402,117 @@ final class FormattingProvider( } def validateWorkspace(projectRoot: AbsolutePath): Future[Unit] = { - if (scalafmtConf(projectRoot).exists) { - val text = scalafmtConf(projectRoot).toInputFromBuffers(buffers).text - ScalafmtConfig.parse(text) match { - case Failure(e) => - scribe.error(s"Failed to parse ${scalafmtConf(projectRoot)}", e) - Future.unit - case Success(values) => - checkIfDialectUpgradeRequired(values, projectRoot) - } - } else { - Future.unit + scalafmtConf(projectRoot) match { + case Some(conf) => + val text = conf.toInputFromBuffers(buffers).text + ScalafmtConfig.parse(text) match { + case Failure(e) => + scribe.error(s"Failed to parse ${conf}", e) + Future.unit + case Success(values) => + checkIfDialectUpgradeRequired(values, conf) + } + case None => + Future.unit } } - private def scalafmtConf(projectRoot: AbsolutePath): AbsolutePath = { + private def scalafmtConf(projectRoot: AbsolutePath): Option[AbsolutePath] = { val configpath = userConfig().scalafmtConfigPath - configpath.getOrElse(projectRoot.resolve(".scalafmt.conf")) + val default: Option[AbsolutePath] = { + val defaultLocation = projectRoot.resolve(defaultScalafmtLocation) + val scalacliDefault = + projectRoot.resolve(".scala-build/.scalafmt.conf") + val hiddenDefault = projectRoot.resolve(Directories.hiddenScalafmt) + List(defaultLocation, scalacliDefault, hiddenDefault).find(_.exists) + } + configpath.orElse(default) } - private val activeReporter: ScalafmtReporter = new ScalafmtReporter { - private var downloadingScalafmt = Promise[Unit]() - override def error(file: Path, message: String): Unit = { - scribe.error(s"scalafmt: $file: $message") - if (file == scalafmtConf(workspace).toNIO) { - downloadingScalafmt.trySuccess(()) - if (message.contains("failed to resolve Scalafmt version")) { - client.showMessage(MissingScalafmtVersion.failedToResolve(message)) - } - val input = scalafmtConf(workspace).toInputFromBuffers(buffers) - val pos = Position.Range(input, 0, input.chars.length) - client.publishDiagnostics( - new l.PublishDiagnosticsParams( - file.toUri.toString, - Collections.singletonList( - new l.Diagnostic( - new l.Range( - new l.Position(0, 0), - new l.Position(pos.endLine, pos.endColumn), + private def activeReporter(projectRoot: AbsolutePath): ScalafmtReporter = + new ScalafmtReporter { + private var downloadingScalafmt = Promise[Unit]() + override def error(file: Path, message: String): Unit = { + scribe.error(s"scalafmt: $file: $message") + scalafmtConf(projectRoot) match { + case Some(conf) if file == conf.toNIO => + downloadingScalafmt.trySuccess(()) + if (message.contains("failed to resolve Scalafmt version")) { + client.showMessage( + MissingScalafmtVersion.failedToResolve(message) + ) + } + val input = conf.toInputFromBuffers(buffers) + val pos = Position.Range(input, 0, input.chars.length) + client.publishDiagnostics( + new l.PublishDiagnosticsParams( + file.toUri.toString, + Collections.singletonList( + new l.Diagnostic( + new l.Range( + new l.Position(0, 0), + new l.Position(pos.endLine, pos.endColumn), + ), + message, + l.DiagnosticSeverity.Error, + "scalafmt", + ) ), - message, - l.DiagnosticSeverity.Error, - "scalafmt", ) - ), - ) - ) + ) + case _ => + } } - } - override def error(file: Path, e: Throwable): Unit = { - downloadingScalafmt.trySuccess(()) - e match { - case p: PositionException => - statusBar.addMessage( - s"${icons.alert}line ${p.startLine() + 1}: ${p.shortMessage()}" - ) - scribe.error(s"scalafmt: ${p.longMessage()}") - case _ => - scribe.error(s"scalafmt: $file", e) + override def error(file: Path, e: Throwable): Unit = { + downloadingScalafmt.trySuccess(()) + e match { + case p: PositionException => + statusBar.addMessage( + s"${icons.alert}line ${p.startLine() + 1}: ${p.shortMessage()}" + ) + scribe.error(s"scalafmt: ${p.longMessage()}") + case _ => + scribe.error(s"scalafmt: $file", e) + } + } + override def excluded(file: Path): Unit = { + scribe.info( + s"scalafmt: excluded $file (to format this file, update `project.excludeFilters` in .scalafmt.conf)" + ) } - } - override def excluded(file: Path): Unit = { - scribe.info( - s"scalafmt: excluded $file (to format this file, update `project.excludeFilters` in .scalafmt.conf)" - ) - } - override def parsedConfig(config: Path, scalafmtVersion: String): Unit = { - downloadingScalafmt.trySuccess(()) - clearDiagnostics(AbsolutePath(config)) - } + override def parsedConfig(config: Path, scalafmtVersion: String): Unit = { + downloadingScalafmt.trySuccess(()) + clearDiagnostics(AbsolutePath(config)) + } - override def missingVersion(config: Path, defaultVersion: String): Unit = { - val promise = Promise[Boolean]() - reporterPromise.set(Some(promise)) - promise.completeWith(handleMissingVersion(AbsolutePath(config))) - super.missingVersion(config, defaultVersion) - } + override def missingVersion( + config: Path, + defaultVersion: String, + ): Unit = { + val promise = Promise[Boolean]() + reporterPromise.set(Some(promise)) + promise.completeWith(handleMissingVersion(AbsolutePath(config))) + super.missingVersion(config, defaultVersion) + } - def downloadOutputStreamWriter(): OutputStreamWriter = - new OutputStreamWriter(downloadOutputStream()) + def downloadOutputStreamWriter(): OutputStreamWriter = + new OutputStreamWriter(downloadOutputStream()) - def downloadOutputStream(): OutputStream = { - downloadingScalafmt.trySuccess(()) - downloadingScalafmt = Promise() - statusBar.trackSlowFuture( - "Loading Scalafmt", - downloadingScalafmt.future, - ) - System.out - } - override def downloadWriter(): PrintWriter = { - new PrintWriter(downloadOutputStream()) + def downloadOutputStream(): OutputStream = { + downloadingScalafmt.trySuccess(()) + downloadingScalafmt = Promise() + statusBar.trackSlowFuture( + "Loading Scalafmt", + downloadingScalafmt.future, + ) + System.out + } + override def downloadWriter(): PrintWriter = { + new PrintWriter(downloadOutputStream()) + } } - } } object FormattingProvider { diff --git a/metals/src/main/scala/scala/meta/internal/metals/Messages.scala b/metals/src/main/scala/scala/meta/internal/metals/Messages.scala index cdd710306b7..317091cb03c 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/Messages.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/Messages.scala @@ -12,6 +12,7 @@ import scala.meta.internal.metals.clients.language.MetalsSlowTaskParams import scala.meta.internal.metals.clients.language.MetalsStatusParams import scala.meta.internal.semver.SemVer import scala.meta.io.AbsolutePath +import scala.meta.io.RelativePath import org.eclipse.lsp4j.MessageActionItem import org.eclipse.lsp4j.MessageParams @@ -615,19 +616,20 @@ object Messages { else "" def createFile = new MessageActionItem("Create .scalafmt.conf") + def runDefaults = new MessageActionItem("Run anyway") - def fixedParams(isAgain: Boolean): MessageParams = + def fixedParams(where: RelativePath): MessageParams = new MessageParams( MessageType.Info, - s"Created .scalafmt.conf${tryAgain(isAgain)}.", + s"Created $where.", ) def isCreateScalafmtConf(params: ShowMessageRequestParams): Boolean = params.getMessage == createScalafmtConfMessage def createScalafmtConfMessage: String = - s"Unable to format since this workspace has no .scalafmt.conf file. " + - s"To fix this problem, create an empty .scalafmt.conf and try again." + s"No .scalafmt.conf file detected. " + + s"How would you like to proceed:" def params(): ShowMessageRequestParams = { val params = new ShowMessageRequestParams() @@ -636,8 +638,8 @@ object Messages { params.setActions( List( createFile, + runDefaults, notNow, - dontShowAgain, ).asJava ) params diff --git a/tests/unit/src/test/scala/tests/FormattingLspSuite.scala b/tests/unit/src/test/scala/tests/FormattingLspSuite.scala index 444ed808250..28fce6e9ba5 100644 --- a/tests/unit/src/test/scala/tests/FormattingLspSuite.scala +++ b/tests/unit/src/test/scala/tests/FormattingLspSuite.scala @@ -95,6 +95,35 @@ class FormattingLspSuite extends BaseLspSuite("formatting") { } yield () } + test("initial-config-hidden") { + cleanWorkspace() + client.showMessageRequestHandler = { params => + val expected = MissingScalafmtConf.createScalafmtConfMessage + if (params.getMessage() == expected) { + params.getActions().asScala.find(_ == MissingScalafmtConf.runDefaults) + } else None + } + for { + _ <- initialize( + s"""|/metals.json + |{"a":{"scalaVersion" : ${V.scala213}}} + |/a/src/main/scala/a/Main.scala + |object FormatMe { + | val x = 1 } + |""".stripMargin, + expectError = true, + ) + _ <- server.didOpen("a/src/main/scala/a/Main.scala") + _ <- server.formatting("a/src/main/scala/a/Main.scala") + _ = assertNoDiff( + server.textContents(".metals/.scalafmt.conf"), + s"""|version = "${V.scalafmtVersion}" + |runner.dialect = scala213 + |""".stripMargin, + ) + } yield () + } + test("custom-config-path") { for { _ <- initialize(