diff --git a/metals/src/main/scala/scala/meta/internal/metals/MetalsLspService.scala b/metals/src/main/scala/scala/meta/internal/metals/MetalsLspService.scala index 98530f026e8f..dc9c5d09acf7 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/MetalsLspService.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/MetalsLspService.scala @@ -477,6 +477,9 @@ abstract class MetalsLspService( scalaVersionSelector, clientConfig.icons(), clientConfig.isReadClipboardProvider(), + buildTargets, + trees, + () => userConfig, onCreate = path => { onCreate(path) onChange(List(path)) diff --git a/metals/src/main/scala/scala/meta/internal/metals/PackageProvider.scala b/metals/src/main/scala/scala/meta/internal/metals/PackageProvider.scala index 35e6c4a0eed2..22474104a653 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/PackageProvider.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/PackageProvider.scala @@ -51,6 +51,7 @@ class PackageProvider( def packageStatement( path: AbsolutePath, fileContent: String = "", + braceless: Boolean = false, ): Option[NewFileTemplate] = { def packageObjectStatement( @@ -63,12 +64,20 @@ class PackageProvider( packageParts.lastOption.map { packageObjectName => val indent = " " val backtickedName = wrap(packageObjectName) - NewFileTemplate( - s"""|${packageDeclaration}package object $backtickedName { - |${indent}@@ - |} - |""".stripMargin - ) + // A bodyless `package object foo` compiles, whereas `package object foo:` + // with an empty indented region is a parse error. + if (braceless) + NewFileTemplate( + s"""|${packageDeclaration}package object $backtickedName@@ + |""".stripMargin + ) + else + NewFileTemplate( + s"""|${packageDeclaration}package object $backtickedName { + |${indent}@@ + |} + |""".stripMargin + ) } } diff --git a/metals/src/main/scala/scala/meta/internal/metals/UserConfiguration.scala b/metals/src/main/scala/scala/meta/internal/metals/UserConfiguration.scala index 96491515b289..a02020a83f27 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/UserConfiguration.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/UserConfiguration.scala @@ -45,6 +45,8 @@ case class UserConfiguration( inlayHintsOptions: InlayHintsOptions = InlayHintsOptions(Map.empty), enableStripMarginOnTypeFormatting: Boolean = true, enableIndentOnPaste: Boolean = false, + newFilesBracelessSyntax: NewFilesBracelessSyntax = + NewFilesBracelessSyntax.Auto, enableSemanticHighlighting: Boolean = true, excludedPackages: Option[List[String]] = None, fallbackScalaVersion: Option[String] = None, @@ -114,6 +116,11 @@ case class UserConfiguration( ) ), Some(("enableIndentOnPaste", enableIndentOnPaste)), + Some( + "newFilesBracelessSyntax" -> newFilesBracelessSyntax + .toString() + .toLowerCase() + ), Some( ( "enableSemanticHighlighting", @@ -468,6 +475,20 @@ object UserConfiguration { |""".stripMargin, isBoolean = true, ), + UserConfigurationOption( + "new-files-braceless-syntax", + "auto", + "always", + "Braceless syntax for newly generated Scala 3 files", + """|Whether new Scala 3 files (from the "New Scala File" command, including the + |"Package Object" kind) use optional-braces / significant-indentation syntax + |instead of curly braces. "auto" matches the style of nearby existing sources + |(the target package directory and its enclosing directories up to the source + |root, falling back to the `-indent` / `-no-indent` compiler option, then + |braces); "always" always uses braceless syntax; "never" always uses braces. + |Has no effect on Scala 2 sources.""".stripMargin, + values = Some(List("auto", "always", "never")), + ), UserConfigurationOption( "fallback-scala-version", BuildInfo.scala3, @@ -844,6 +865,14 @@ object UserConfiguration { getBooleanKey("enable-strip-margin-on-type-formatting").getOrElse(true) val enableIndentOnPaste = getBooleanKey("enable-indent-on-paste").getOrElse(true) + val newFilesBracelessSyntax = + getStringKey("new-files-braceless-syntax").map( + _.trim().toLowerCase() + ) match { + case Some("always") => NewFilesBracelessSyntax.Always + case Some("never") => NewFilesBracelessSyntax.Never + case _ => NewFilesBracelessSyntax.Auto + } val enableSemanticHighlighting = getBooleanKey("enable-semantic-highlighting").getOrElse(true) val excludedPackages = @@ -931,6 +960,7 @@ object UserConfiguration { inlayHintsOptions, enableStripMarginOnTypeFormatting, enableIndentOnPaste, + newFilesBracelessSyntax, enableSemanticHighlighting, excludedPackages, defaultScalaVersion, @@ -993,3 +1023,16 @@ object AutoImportBuildKind { case object Initial extends AutoImportBuildKind case object All extends AutoImportBuildKind } + +sealed trait NewFilesBracelessSyntax +object NewFilesBracelessSyntax { + + /** Match the style already used in the project. */ + case object Auto extends NewFilesBracelessSyntax + + /** Always generate braceless (significant-indentation) Scala 3 code. */ + case object Always extends NewFilesBracelessSyntax + + /** Always generate braced code. */ + case object Never extends NewFilesBracelessSyntax +} diff --git a/metals/src/main/scala/scala/meta/internal/metals/newScalaFile/BracelessSyntax.scala b/metals/src/main/scala/scala/meta/internal/metals/newScalaFile/BracelessSyntax.scala new file mode 100644 index 000000000000..9eb5616b30e5 --- /dev/null +++ b/metals/src/main/scala/scala/meta/internal/metals/newScalaFile/BracelessSyntax.scala @@ -0,0 +1,62 @@ +package scala.meta.internal.metals.newScalaFile + +import scala.annotation.tailrec + +import scala.meta._ +import scala.meta.tokens.{Token => T} + +/** + * Detects whether a Scala source prefers the optional-braces + * (significant-indentation) style over curly braces. + * + * This lets `NewFileProvider` match the style of the surrounding project when + * generating new files, instead of relying on a user setting. + */ +object BracelessSyntax { + + /** + * Whether `tree` prefers braceless syntax, based on its first top-level type + * definition that has a body. + * + * - `Some(true)` the first such definition uses significant indentation + * - `Some(false)` it uses curly braces + * - `None` no top-level definition with a body was found + */ + def prefersBraceless(tree: Tree): Option[Boolean] = + topLevelTemplates(tree).flatMap(bodyStyle).nextOption() + + /** Templates of top-level type definitions, descending into packages. */ + private def topLevelTemplates(tree: Tree): Iterator[Template] = + tree match { + case Source(stats) => stats.iterator.flatMap(topLevelTemplates) + case Pkg(_, stats) => stats.iterator.flatMap(topLevelTemplates) + case t: Pkg.Object => Iterator.single(t.templ) + case t: Defn.Class => Iterator.single(t.templ) + case t: Defn.Trait => Iterator.single(t.templ) + case t: Defn.Object => Iterator.single(t.templ) + case t: Defn.Enum => Iterator.single(t.templ) + case _ => Iterator.empty + } + + /** + * Whether a template body is opened by significant indentation rather than a + * brace. We look for the first `{` or `:` at the top level of the template, + * skipping anything nested in the parent constructor's parentheses/brackets + * (and thus ignoring a member's own braces, which come after the opener). + */ + private def bodyStyle(template: Template): Option[Boolean] = { + @tailrec + def loop(tokens: List[T], depth: Int): Option[Boolean] = + tokens match { + case Nil => None + case (_: T.LeftParen | _: T.LeftBracket) :: rest => + loop(rest, depth + 1) + case (_: T.RightParen | _: T.RightBracket) :: rest => + loop(rest, depth - 1) + case (_: T.LeftBrace) :: _ if depth == 0 => Some(false) + case (_: T.Colon) :: _ if depth == 0 => Some(true) + case _ :: rest => loop(rest, depth) + } + loop(template.tokens.toList, 0) + } +} diff --git a/metals/src/main/scala/scala/meta/internal/metals/newScalaFile/NewFileProvider.scala b/metals/src/main/scala/scala/meta/internal/metals/newScalaFile/NewFileProvider.scala index 001885a929bc..7881b683562f 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/newScalaFile/NewFileProvider.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/newScalaFile/NewFileProvider.scala @@ -3,23 +3,28 @@ package scala.meta.internal.metals.newScalaFile import java.net.URI import java.nio.file.FileAlreadyExistsException +import scala.annotation.tailrec import scala.concurrent.ExecutionContext import scala.concurrent.Future import scala.util.Properties import scala.util.control.NonFatal import scala.meta.internal.builds.NewProjectProvider +import scala.meta.internal.metals.BuildTargets import scala.meta.internal.metals.ClientCommands import scala.meta.internal.metals.Icons import scala.meta.internal.metals.Messages.NewScalaFile import scala.meta.internal.metals.MetalsEnrichments._ +import scala.meta.internal.metals.NewFilesBracelessSyntax import scala.meta.internal.metals.PackageProvider import scala.meta.internal.metals.ScalaVersionSelector import scala.meta.internal.metals.ScalaVersions +import scala.meta.internal.metals.UserConfiguration import scala.meta.internal.metals.clients.language.MetalsInputBoxParams import scala.meta.internal.metals.clients.language.MetalsLanguageClient import scala.meta.internal.metals.clients.language.MetalsQuickPickParams import scala.meta.internal.metals.newScalaFile.NewFileTypes._ +import scala.meta.internal.parsing.Trees import scala.meta.internal.pc.Identifier import scala.meta.io.AbsolutePath @@ -33,6 +38,9 @@ class NewFileProvider( selector: ScalaVersionSelector, icons: Icons, isReadClipboardProvider: Boolean, + buildTargets: BuildTargets, + trees: Trees, + userConfig: () => UserConfiguration, onCreate: AbsolutePath => Future[Unit], )(implicit ec: ExecutionContext @@ -241,11 +249,13 @@ class NewFileProvider( val path = directory.resolve(fileName) // name can be "foo/Name" or "Foo.scala"; use path filename without ext for template val className = Identifier.backtickWrap(path.filename.stripSuffix(ext)) + val useBraceless = useBracelessSyntax(path) val template = kind match { case CaseClass => caseClassTemplate(className) - case Enum => enumTemplate(className) + case Enum => enumTemplate(className, useBraceless) case JavaRecord => javaRecordTemplate(className) - case _ => classTemplate(kind.syntax.getOrElse(""), className) + case _ => + classTemplate(kind.syntax.getOrElse(""), className, useBraceless) } val editText = template.map { s => packageProvider @@ -277,11 +287,115 @@ class NewFileProvider( createFileAndWriteText( path, packageProvider - .packageStatement(path) + .packageStatement(path, braceless = useBracelessSyntax(path)) .getOrElse(NewFileTemplate.empty), ) } + /** + * Whether generated code for `path` should use Scala 3's optional-braces + * (significant-indentation) syntax instead of curly braces. + * + * Only Scala 3 supports braceless syntax, and the compiler can still forbid + * it (`-no-indent`, `-old-syntax`, migration mode) — those cases always use + * braces, overriding the user's preference. Otherwise the + * `new-files-braceless-syntax` setting decides: `always`/`never` force the + * choice, while `auto` (the default) matches the style of nearby existing + * sources — the target package directory and its enclosing directories up to + * the source root — then the `-indent` scalac option, and finally braces. + */ + private def useBracelessSyntax(path: AbsolutePath): Boolean = + path.isScalaFilename && + ScalaVersions.isScala3Version(selector.scalaVersionForPath(path)) && { + val options = scalacOptions(path) + significantIndentationAllowed(options) && { + userConfig().newFilesBracelessSyntax match { + case NewFilesBracelessSyntax.Always => true + case NewFilesBracelessSyntax.Never => false + case NewFilesBracelessSyntax.Auto => + styleFromExistingSources(path) + .orElse(indentationPreferredByScalac(options)) + .getOrElse(false) + } + } + } + + /** + * Braceless preference inferred from an existing source file, if any. + * + * Directories are searched nearest-first (the file's own package directory, + * then each enclosing directory up to the source root), and each directory's + * own `.scala` files are inspected in a stable, filename-sorted order. The + * search is lazy and stops at the first file that yields a determinate style, + * so it neither depends on filesystem ordering nor walks the whole tree. + */ + private def styleFromExistingSources( + path: AbsolutePath + ): Option[Boolean] = + directoriesNearestFirst( + path.parent, + buildTargets.inverseSourceItem(path), + ).iterator + .flatMap(styleInDirectory(_, exclude = path)) + .nextOption() + + private def styleInDirectory( + directory: AbsolutePath, + exclude: AbsolutePath, + ): Option[Boolean] = + directory.list.toList + .filter(file => file.isScalaFilename && file != exclude) + .sortBy(_.filename) + .iterator + .flatMap(trees.get) + .flatMap(BracelessSyntax.prefersBraceless) + .nextOption() + + /** `directory` followed by its ancestors up to and including `sourceRoot`. */ + private def directoriesNearestFirst( + directory: AbsolutePath, + sourceRoot: Option[AbsolutePath], + ): List[AbsolutePath] = + sourceRoot match { + case Some(root) if directory.toNIO.startsWith(root.toNIO) => + @tailrec + def loop( + current: AbsolutePath, + acc: List[AbsolutePath], + ): List[AbsolutePath] = { + val next = current :: acc + if (current == root) next.reverse + else loop(current.parent, next) + } + loop(directory, Nil) + case _ => List(directory) + } + + private def scalacOptions(path: AbsolutePath): List[String] = + buildTargets + .inverseSources(path) + .flatMap(buildTargets.scalaTarget) + .map(_.options) + .getOrElse(Nil) + + /** + * Whether the compiler allows significant indentation. `-no-indent`, + * `-old-syntax` and `-source:-migration` all require classical braces. + */ + private def significantIndentationAllowed( + options: List[String] + ): Boolean = + !options.exists { option => + option == "-no-indent" || option == "-old-syntax" || + option.endsWith("-migration") + } + + /** Braceless preference from an explicit `-indent` scalac option, if any. */ + private def indentationPreferredByScalac( + options: List[String] + ): Option[Boolean] = + if (options.contains("-indent")) Some(true) else None + private def createEmptyFile( directory: AbsolutePath, name: String, @@ -326,20 +440,39 @@ class NewFileProvider( ) } - private def classTemplate(kind: String, name: String): NewFileTemplate = { + private def classTemplate( + kind: String, + name: String, + braceless: Boolean, + ): NewFileTemplate = { val indent = " " - NewFileTemplate(s"""|$kind $name { - |$indent@@ - |} - |""".stripMargin) + // A bodyless declaration is the braceless equivalent of an empty class/trait/ + // object: `class Foo:` with an empty indented region is a parse error, whereas + // `class Foo` compiles. The cursor lands after the name, ready for a body. + if (braceless) + NewFileTemplate(s"""|$kind $name@@ + |""".stripMargin) + else + NewFileTemplate(s"""|$kind $name { + |$indent@@ + |} + |""".stripMargin) } - private def enumTemplate(name: String): NewFileTemplate = { + private def enumTemplate( + name: String, + braceless: Boolean, + ): NewFileTemplate = { val indent = " " - NewFileTemplate(s"""|enum $name { - |${indent}case@@ - |} - |""".stripMargin) + if (braceless) + NewFileTemplate(s"""|enum $name: + |${indent}case@@ + |""".stripMargin) + else + NewFileTemplate(s"""|enum $name { + |${indent}case@@ + |} + |""".stripMargin) } private def javaRecordTemplate(name: String): NewFileTemplate = { diff --git a/tests/unit/src/test/scala/tests/BracelessSyntaxSuite.scala b/tests/unit/src/test/scala/tests/BracelessSyntaxSuite.scala new file mode 100644 index 000000000000..b7f82da6b875 --- /dev/null +++ b/tests/unit/src/test/scala/tests/BracelessSyntaxSuite.scala @@ -0,0 +1,62 @@ +package tests + +import scala.meta._ +import scala.meta.internal.metals.newScalaFile.BracelessSyntax + +class BracelessSyntaxSuite extends munit.FunSuite { + + private def check( + name: String, + code: String, + expected: Option[Boolean], + ): Unit = + test(name) { + val tree = + dialects + .Scala3(Input.VirtualFile(s"$name.scala", code)) + .parse[Source] + .get + assertEquals(BracelessSyntax.prefersBraceless(tree), expected) + } + + check("braces-class", "class Foo { def x = 1 }", Some(false)) + check("braceless-class", "class Foo:\n def x = 1", Some(true)) + check("object-braceless", "object O:\n def y = 2", Some(true)) + check("trait-braces", "trait T { type A }", Some(false)) + check( + "package-object-braceless", + "package object p:\n def y = 2", + Some(true), + ) + check("package-object-braces", "package object p { def y = 2 }", Some(false)) + check("enum-braceless", "enum E:\n case A, B", Some(true)) + + // The parent clause must not confuse the brace detection. + check( + "braces-with-parent", + "class Foo extends Bar { def x = 1 }", + Some(false), + ) + check( + "braceless-with-parent", + "class Foo extends Bar:\n def x = 1", + Some(true), + ) + check( + "braceless-parent-args", + "class Foo extends Bar[Int](1):\n def x = 1", + Some(true), + ) + check("braceless-derives", "class Foo derives Eq:\n def x = 1", Some(true)) + + // A member's own braces come after the body opener, so they don't count. + check("braceless-member-braces", "class Foo:\n def x = { 1 }", Some(true)) + // A self-type opens the body with a brace. + check("self-type-braces", "trait T { self: Any => def a = 1 }", Some(false)) + + // A bodyless declaration gives no signal; fall through to the next one. + check("bodyless-none", "class Foo", None) + check("first-with-body-wins", "class A\nobject B:\n def y = 1", Some(true)) + check("package-wrapped", "package p\n\nclass Foo:\n def x = 1", Some(true)) + check("empty-file", "", None) +} diff --git a/tests/unit/src/test/scala/tests/NewFileLspSuite.scala b/tests/unit/src/test/scala/tests/NewFileLspSuite.scala index 867864a12933..40356aea264e 100644 --- a/tests/unit/src/test/scala/tests/NewFileLspSuite.scala +++ b/tests/unit/src/test/scala/tests/NewFileLspSuite.scala @@ -3,6 +3,7 @@ package tests import java.nio.file.FileAlreadyExistsException import java.nio.file.Files +import scala.concurrent.Future import scala.util.Properties import scala.meta.internal.metals.InitializationOptions @@ -351,6 +352,243 @@ class NewFileLspSuite extends BaseLspSuite("new-file") { scalaVersion = Some(V.scala3), ) + // With no existing sources and no scalac flags, Scala 3 defaults to braces. + checkScala("scala3-class-default-braces")( + directory = Some("a/src/main/scala/foo/"), + fileType = Right(Class), + fileName = Right("Foo"), + expectedFilePath = "a/src/main/scala/foo/Foo.scala", + expectedContent = s"""|package foo + | + |class Foo { + |$indent + |} + |""".stripMargin, + scalaVersion = Some(V.scala3), + ) + + // The style is inferred from an existing source file. A braceless sibling + // yields bodyless stubs (a `class Foo:` with an empty body is a parse error). + checkScala("braceless-class-from-sibling")( + directory = Some("a/src/main/scala/foo/"), + fileType = Right(Class), + fileName = Right("Foo"), + expectedFilePath = "a/src/main/scala/foo/Foo.scala", + expectedContent = """|package foo + | + |class Foo + |""".stripMargin, + scalaVersion = Some(V.scala3), + existingFiles = """|/a/src/main/scala/foo/Existing.scala + |package foo + | + |object Existing: + | def value = 1 + |""".stripMargin, + ) + + // The style is inferred from the file's own target package, even when the + // name selects a subdirectory (e.g. `bar/Baz` created from the source root). + checkScala("braceless-class-from-sibling-in-subdirectory")( + directory = Some("a/src/main/scala/"), + fileType = Right(Class), + fileName = Right("bar/Baz"), + expectedFilePath = "a/src/main/scala/bar/Baz.scala", + expectedContent = """|package bar + | + |class Baz + |""".stripMargin, + scalaVersion = Some(V.scala3), + existingFiles = """|/a/src/main/scala/bar/Existing.scala + |package bar + | + |object Existing: + | def value = 1 + |""".stripMargin, + ) + + // A braceful sibling keeps braces even on Scala 3. + checkScala("braces-class-from-sibling")( + directory = Some("a/src/main/scala/foo/"), + fileType = Right(Class), + fileName = Right("Foo"), + expectedFilePath = "a/src/main/scala/foo/Foo.scala", + expectedContent = s"""|package foo + | + |class Foo { + |$indent + |} + |""".stripMargin, + scalaVersion = Some(V.scala3), + existingFiles = """|/a/src/main/scala/foo/Existing.scala + |package foo + | + |object Existing { + | def value = 1 + |} + |""".stripMargin, + ) + + // Case classes have no body, so they stay the same regardless of the style. + checkScala("case-class-unaffected-by-braceless")( + directory = Some("a/src/main/scala/foo/"), + fileType = Right(CaseClass), + fileName = Right("Foo"), + expectedFilePath = "a/src/main/scala/foo/Foo.scala", + expectedContent = """|package foo + | + |final case class Foo() + |""".stripMargin, + scalaVersion = Some(V.scala3), + existingFiles = """|/a/src/main/scala/foo/Existing.scala + |package foo + | + |object Existing: + | def value = 1 + |""".stripMargin, + ) + + // Enum keeps the colon form: a bodyless `enum` doesn't compile (it needs + // cases), so the braceless variant mirrors the incomplete braceful `case` stub. + checkScala("braceless-enum-from-sibling")( + directory = Some("a/src/main/scala/foo"), + fileType = Right(Enum), + fileName = Right("Color"), + expectedFilePath = "a/src/main/scala/foo/Color.scala", + expectedContent = s"""|package foo + | + |enum Color: + |${indent}case + |""".stripMargin, + scalaVersion = Some(V.scala3), + existingFiles = """|/a/src/main/scala/foo/Existing.scala + |package foo + | + |object Existing: + | def value = 1 + |""".stripMargin, + ) + + checkScala("braceless-package-object-from-sibling")( + directory = Some("a/src/main/scala/foo"), + fileType = Right(PackageObject), + fileName = Right(""), + expectedFilePath = "a/src/main/scala/foo/package.scala", + expectedContent = """|package object foo + |""".stripMargin, + scalaVersion = Some(V.scala3), + existingFiles = """|/a/src/main/scala/foo/Existing.scala + |package foo + | + |object Existing: + | def value = 1 + |""".stripMargin, + ) + + // Without existing sources, the `-indent` scalac option selects braceless. + checkScala("braceless-from-scalac-indent")( + directory = Some("a/src/main/scala/foo/"), + fileType = Right(Class), + fileName = Right("Foo"), + expectedFilePath = "a/src/main/scala/foo/Foo.scala", + expectedContent = """|package foo + | + |class Foo + |""".stripMargin, + scalaVersion = Some(V.scala3), + scalacOptions = List("-indent"), + ) + + // `always` forces braceless regardless of surrounding sources. + checkScala("braceless-syntax-always")( + directory = Some("a/src/main/scala/foo/"), + fileType = Right(Class), + fileName = Right("Foo"), + expectedFilePath = "a/src/main/scala/foo/Foo.scala", + expectedContent = """|package foo + | + |class Foo + |""".stripMargin, + scalaVersion = Some(V.scala3), + bracelessSyntax = Some("always"), + ) + + // `never` forces braces even next to a braceless sibling. + checkScala("braceless-syntax-never")( + directory = Some("a/src/main/scala/foo/"), + fileType = Right(Class), + fileName = Right("Foo"), + expectedFilePath = "a/src/main/scala/foo/Foo.scala", + expectedContent = s"""|package foo + | + |class Foo { + |$indent + |} + |""".stripMargin, + scalaVersion = Some(V.scala3), + existingFiles = """|/a/src/main/scala/foo/Existing.scala + |package foo + | + |object Existing: + | def value = 1 + |""".stripMargin, + bracelessSyntax = Some("never"), + ) + + // The compiler capability overrides the preference: `-no-indent` forbids + // significant indentation, so even `always` must use braces. + checkScala("no-indent-overrides-always")( + directory = Some("a/src/main/scala/foo/"), + fileType = Right(Class), + fileName = Right("Foo"), + expectedFilePath = "a/src/main/scala/foo/Foo.scala", + expectedContent = s"""|package foo + | + |class Foo { + |$indent + |} + |""".stripMargin, + scalaVersion = Some(V.scala3), + scalacOptions = List("-no-indent"), + bracelessSyntax = Some("always"), + ) + + // `always` still has no effect on Scala 2 (braceless syntax is Scala 3 only). + checkScala("braceless-syntax-always-ignored-on-scala2")( + directory = Some("a/src/main/scala/foo/"), + fileType = Right(Class), + fileName = Right("Foo"), + expectedFilePath = "a/src/main/scala/foo/Foo.scala", + expectedContent = s"""|package foo + | + |class Foo { + |$indent + |} + |""".stripMargin, + bracelessSyntax = Some("always"), + ) + + // Scala 2 never uses braceless, even with `always` and a braceless sibling. + checkScala("scala2-always-braces")( + directory = Some("a/src/main/scala/foo/"), + fileType = Right(Class), + fileName = Right("Foo"), + expectedFilePath = "a/src/main/scala/foo/Foo.scala", + expectedContent = s"""|package foo + | + |class Foo { + |$indent + |} + |""".stripMargin, + existingFiles = """|/a/src/main/scala/foo/Existing.scala + |package foo + | + |object Existing: + | def value = 1 + |""".stripMargin, + bracelessSyntax = Some("always"), + ) + checkScala("empty-file-with-package")( directory = Some("a/src/main/scala/foo"), fileType = Right(ScalaFile), @@ -628,6 +866,8 @@ class NewFileLspSuite extends BaseLspSuite("new-file") { expectedException: List[Class[_]] = Nil, scalaVersion: Option[String] = None, expectedSnippet: Option[String] = None, + scalacOptions: List[String] = Nil, + bracelessSyntax: Option[String] = None, )(implicit loc: Location): Unit = check(testName)( directory, fileType, @@ -639,6 +879,8 @@ class NewFileLspSuite extends BaseLspSuite("new-file") { expectedException, scalaVersion, expectedSnippet, + scalacOptions, + bracelessSyntax, ) /** @@ -656,6 +898,8 @@ class NewFileLspSuite extends BaseLspSuite("new-file") { expectedException: List[Class[_]], scalaVersion: Option[String], expectedSnippet: Option[String] = None, + scalacOptions: List[String] = Nil, + bracelessSyntax: Option[String] = None, )(implicit loc: Location): Unit = test(testName) { val localScalaVersion = scalaVersion.getOrElse(V.scala213) @@ -725,11 +969,18 @@ class NewFileLspSuite extends BaseLspSuite("new-file") { fileType.fold(ft => ft.id, _ => null.asInstanceOf[String]), ) + val scalacOptionsJson = + if (scalacOptions.isEmpty) "" + else + scalacOptions + .map(opt => s""""$opt"""") + .mkString(""", "scalacOptions": [""", ", ", "]") + val futureToRecover = for { _ <- initialize( s"""/metals.json |{ - | "a": { "scalaVersion" : "$localScalaVersion" } + | "a": { "scalaVersion" : "$localScalaVersion"$scalacOptionsJson } |} |/focusedDoc.txt | @@ -737,6 +988,16 @@ class NewFileLspSuite extends BaseLspSuite("new-file") { """.stripMargin ) _ <- server.didFocus("focusedDoc.txt") + _ <- bracelessSyntax match { + case Some(mode) => + server.didChangeConfiguration( + s"""|{ + | "new-files-braceless-syntax": "$mode" + |} + |""".stripMargin + ) + case None => Future.unit + } _ <- server.executeCommand(command, args: _*) _ = { assertNoDiff( diff --git a/tests/unit/src/test/scala/tests/UserConfigurationSuite.scala b/tests/unit/src/test/scala/tests/UserConfigurationSuite.scala index 71c37cddef24..684711e823ef 100644 --- a/tests/unit/src/test/scala/tests/UserConfigurationSuite.scala +++ b/tests/unit/src/test/scala/tests/UserConfigurationSuite.scala @@ -301,7 +301,6 @@ class UserConfigurationSuite extends BaseSuite { | "b", | "c" | ], - | "enableStripMarginOnTypeFormatting": false, | "gradleScript": "gradle", | "scalafixConfigPath": "$fakePathString", | "superMethodLensesEnabled": true, @@ -335,6 +334,8 @@ class UserConfigurationSuite extends BaseSuite { | "worksheetCancelTimeout": 10, | "worksheetScreenWidth": 140, | "enableBestEffort": false, + | "newFilesBracelessSyntax": "auto", + | "enableStripMarginOnTypeFormatting": false, | "scalafmtConfigPath": "$fakePathString", | "javaHome": "/fake/home" |} @@ -389,6 +390,7 @@ class UserConfigurationSuite extends BaseSuite { |inlay-hints.closing-labels.enable boolean false Should display closing label hints for methods/classes/objects next to their closing braces |enable-semantic-highlighting boolean true Use semantic tokens highlight |enable-indent-on-paste boolean false Indent snippets when pasted. + |new-files-braceless-syntax [auto,always,never] auto Braceless syntax for newly generated Scala 3 files |fallback-scala-version string $scala3Padded Default fallback Scala version |test-user-interface [code lenses,test explorer] code lenses Test UI used for tests and test suites |java-format.eclipse-config-path string "" Eclipse Java formatter config path