diff --git a/modules/build/src/main/scala/scala/build/CrossSources.scala b/modules/build/src/main/scala/scala/build/CrossSources.scala index 668085f50b..05d5afa9c0 100644 --- a/modules/build/src/main/scala/scala/build/CrossSources.scala +++ b/modules/build/src/main/scala/scala/build/CrossSources.scala @@ -235,6 +235,8 @@ object CrossSources { distinctSources } + logger.flushExperimentalWarnings + val scopedRequirements = preprocessedSources.flatMap(_.scopedRequirements) val scopedRequirementsByRoot = scopedRequirements.groupBy(_.path.root) def baseReqs(path: ScopePath): BuildRequirements = { diff --git a/modules/build/src/main/scala/scala/build/PersistentDiagnosticLogger.scala b/modules/build/src/main/scala/scala/build/PersistentDiagnosticLogger.scala index cffb2004f9..430d33f312 100644 --- a/modules/build/src/main/scala/scala/build/PersistentDiagnosticLogger.scala +++ b/modules/build/src/main/scala/scala/build/PersistentDiagnosticLogger.scala @@ -1,12 +1,13 @@ package scala.build import bloop.rifle.BloopRifleLogger -import org.scalajs.logging.{Logger => ScalaJsLogger} +import org.scalajs.logging.Logger as ScalaJsLogger import java.io.PrintStream import scala.build.errors.{BuildException, Diagnostic} -import scala.scalanative.{build => sn} +import scala.build.internals.FeatureType +import scala.scalanative.build as sn class PersistentDiagnosticLogger(parent: Logger) extends Logger { private val diagBuilder = List.newBuilder[Diagnostic] @@ -39,4 +40,9 @@ class PersistentDiagnosticLogger(parent: Logger) extends Logger { def compilerOutputStream: PrintStream = parent.compilerOutputStream def verbosity: Int = parent.verbosity + + def experimentalWarning(featureName: String, featureType: FeatureType): Unit = + parent.experimentalWarning(featureName, featureType) + + def flushExperimentalWarnings: Unit = parent.flushExperimentalWarnings } diff --git a/modules/build/src/main/scala/scala/build/internal/util/WarningMessages.scala b/modules/build/src/main/scala/scala/build/internal/util/WarningMessages.scala index 0620890e81..49d46916fa 100644 --- a/modules/build/src/main/scala/scala/build/internal/util/WarningMessages.scala +++ b/modules/build/src/main/scala/scala/build/internal/util/WarningMessages.scala @@ -2,27 +2,46 @@ package scala.build.internal.util import scala.build.input.ScalaCliInvokeData import scala.build.internal.Constants +import scala.build.internals.FeatureType import scala.build.preprocessing.directives.{DirectiveHandler, ScopedDirective} import scala.cli.commands.{SpecificationLevel, tags} import scala.cli.config.Key object WarningMessages { private val scalaCliGithubUrl = s"https://github.com/${Constants.ghOrg}/${Constants.ghName}" - private def experimentalFeatureUsed(featureName: String): String = - s"""$featureName is experimental. - |Please bear in mind that non-ideal user experience should be expected. - |If you encounter any bugs or have feedback to share, make sure to reach out to the maintenance team at $scalaCliGithubUrl""".stripMargin - def experimentalDirectiveUsed(name: String): String = - experimentalFeatureUsed(s"The `$name` directive") - def experimentalSubcommandUsed(name: String): String = - experimentalFeatureUsed(s"The `$name` sub-command") + private val experimentalNote = + s"""Please bear in mind that non-ideal user experience should be expected. + |If you encounter any bugs or have feedback to share, make sure to reach out to the maintenance team at $scalaCliGithubUrl""".stripMargin + def experimentalFeaturesUsed(namesAndFeatureTypes: Seq[(String, FeatureType)]): String = { + val message = namesAndFeatureTypes match { + case Seq((name, featureType)) => s"The `$name` $featureType is experimental" + case namesAndTypes => + val nl = System.lineSeparator() + val distinctFeatureTypes = namesAndTypes.map(_._2).distinct + val (bulletPointList, featureNameToPrint) = if (distinctFeatureTypes.size == 1) + ( + namesAndTypes.map((name, fType) => s" - `$name`") + .mkString(nl), + s"${distinctFeatureTypes.head}s" // plural form + ) + else + ( + namesAndTypes.map((name, fType) => s" - `$name` $fType") + .mkString(nl), + "features" + ) - def experimentalOptionUsed(name: String): String = - experimentalFeatureUsed(s"The `$name` option") + s"""Some utilized $featureNameToPrint are marked as experimental: + |$bulletPointList""".stripMargin + } + s"""$message + |$experimentalNote""".stripMargin + } - def experimentalConfigKeyUsed(name: String): String = - experimentalFeatureUsed(s"The `$name` configuration key") + def experimentalSubcommandWarning(name: String): String = + s"""The `$name` sub-command is experimental. + |$experimentalNote""".stripMargin def rawValueNotWrittenToPublishFile( rawValue: String, diff --git a/modules/build/src/main/scala/scala/build/preprocessing/DirectivesPreprocessor.scala b/modules/build/src/main/scala/scala/build/preprocessing/DirectivesPreprocessor.scala index 1b48e4732e..1fc600d258 100644 --- a/modules/build/src/main/scala/scala/build/preprocessing/DirectivesPreprocessor.scala +++ b/modules/build/src/main/scala/scala/build/preprocessing/DirectivesPreprocessor.scala @@ -15,7 +15,7 @@ import scala.build.errors.{ } import scala.build.input.ScalaCliInvokeData import scala.build.internal.util.WarningMessages -import scala.build.internal.util.WarningMessages.experimentalDirectiveUsed +import scala.build.internals.FeatureType import scala.build.options.{ BuildOptions, BuildRequirements, @@ -27,56 +27,24 @@ import scala.build.preprocessing.directives.DirectivesPreprocessingUtils.* import scala.build.preprocessing.directives.PartiallyProcessedDirectives.* import scala.build.preprocessing.directives.* -object DirectivesPreprocessor { - def preprocess( - content: String, - path: Either[String, os.Path], - cwd: ScopePath, - logger: Logger, - allowRestrictedFeatures: Boolean, - suppressWarningOptions: SuppressWarningOptions, - maybeRecoverOnError: BuildException => Option[BuildException] - )(using ScalaCliInvokeData): Either[BuildException, PreprocessedDirectives] = either { - val directives = value { - ExtractedDirectives.from(content.toCharArray, path, logger, maybeRecoverOnError) - } - value { - preprocess( - directives, - path, - cwd, - logger, - allowRestrictedFeatures, - suppressWarningOptions, - maybeRecoverOnError - ) - } - } +case class DirectivesPreprocessor( + path: Either[String, os.Path], + cwd: ScopePath, + logger: Logger, + allowRestrictedFeatures: Boolean, + suppressWarningOptions: SuppressWarningOptions, + maybeRecoverOnError: BuildException => Option[BuildException] +)( + using ScalaCliInvokeData +) { + def preprocess(content: String): Either[BuildException, PreprocessedDirectives] = for { + directives <- ExtractedDirectives.from(content.toCharArray, path, logger, maybeRecoverOnError) + res <- preprocess(directives) + } yield res - def preprocess( - extractedDirectives: ExtractedDirectives, - path: Either[String, os.Path], - cwd: ScopePath, - logger: Logger, - allowRestrictedFeatures: Boolean, - suppressWarningOptions: SuppressWarningOptions, - maybeRecoverOnError: BuildException => Option[BuildException] - )(using ScalaCliInvokeData): Either[BuildException, PreprocessedDirectives] = either { + def preprocess(extractedDirectives: ExtractedDirectives) + : Either[BuildException, PreprocessedDirectives] = either { val ExtractedDirectives(directives, directivesPositions) = extractedDirectives - def preprocessWithDirectiveHandlers[T: ConfigMonoid]( - remainingDirectives: Seq[StrictDirective], - directiveHandlers: Seq[DirectiveHandler[T]] - ): Either[BuildException, PartiallyProcessedDirectives[T]] = - applyDirectiveHandlers( - remainingDirectives, - directiveHandlers, - path, - cwd, - logger, - allowRestrictedFeatures, - suppressWarningOptions, - maybeRecoverOnError - ) val ( buildOptionsWithoutRequirements: PartiallyProcessedDirectives[BuildOptions], @@ -88,16 +56,16 @@ object DirectivesPreprocessor { ) = value { for { regularUsingDirectives: PartiallyProcessedDirectives[BuildOptions] <- - preprocessWithDirectiveHandlers(directives, usingDirectiveHandlers) + applyDirectiveHandlers(directives, usingDirectiveHandlers) usingDirectivesWithRequirements: PartiallyProcessedDirectives[ List[WithBuildRequirements[BuildOptions]] ] <- - preprocessWithDirectiveHandlers( + applyDirectiveHandlers( regularUsingDirectives.unused, usingDirectiveWithReqsHandlers ) targetDirectives: PartiallyProcessedDirectives[BuildRequirements] <- - preprocessWithDirectiveHandlers( + applyDirectiveHandlers( usingDirectivesWithRequirements.unused, requireDirectiveHandlers ) @@ -142,14 +110,8 @@ object DirectivesPreprocessor { private def applyDirectiveHandlers[T: ConfigMonoid]( directives: Seq[StrictDirective], - handlers: Seq[DirectiveHandler[T]], - path: Either[String, os.Path], - cwd: ScopePath, - logger: Logger, - allowRestrictedFeatures: Boolean, - suppressWarningOptions: SuppressWarningOptions, - maybeRecoverOnError: BuildException => Option[BuildException] = e => Some(e) - )(using ScalaCliInvokeData): Either[BuildException, PartiallyProcessedDirectives[T]] = { + handlers: Seq[DirectiveHandler[T]] + ): Either[BuildException, PartiallyProcessedDirectives[T]] = { val configMonoidInstance = implicitly[ConfigMonoid[T]] val shouldSuppressExperimentalFeatures = suppressWarningOptions.suppressExperimentalFeatureWarning.getOrElse(false) @@ -161,11 +123,12 @@ object DirectivesPreprocessor { if !allowRestrictedFeatures && (handler.isRestricted || handler.isExperimental) then Left(DirectiveErrors( ::(WarningMessages.powerDirectiveUsedInSip(scopedDirective, handler), Nil), + // TODO: use positions from ExtractedDirectives to get the full directive underlined DirectiveUtil.positions(scopedDirective.directive.values, path) )) else if handler.isExperimental && !shouldSuppressExperimentalFeatures then - logger.message(experimentalDirectiveUsed(scopedDirective.directive.toString)) + logger.experimentalWarning(scopedDirective.directive.toString, FeatureType.Directive) handler.handleValues(scopedDirective, logger) val handlersMap = handlers diff --git a/modules/build/src/main/scala/scala/build/preprocessing/JavaPreprocessor.scala b/modules/build/src/main/scala/scala/build/preprocessing/JavaPreprocessor.scala index 876819c15a..8dbdcf7d8d 100644 --- a/modules/build/src/main/scala/scala/build/preprocessing/JavaPreprocessor.scala +++ b/modules/build/src/main/scala/scala/build/preprocessing/JavaPreprocessor.scala @@ -47,8 +47,7 @@ final case class JavaPreprocessor( val content: String = value(PreprocessingUtil.maybeRead(j.path)) val scopePath = ScopePath.fromPath(j.path) val preprocessedDirectives: PreprocessedDirectives = value { - DirectivesPreprocessor.preprocess( - content, + DirectivesPreprocessor( Right(j.path), scopePath, logger, @@ -56,6 +55,7 @@ final case class JavaPreprocessor( suppressWarningOptions, maybeRecoverOnError ) + .preprocess(content) } Seq(PreprocessedSource.OnDisk( path = j.path, @@ -89,14 +89,15 @@ final case class JavaPreprocessor( else v.subPath val content = new String(v.content, StandardCharsets.UTF_8) val preprocessedDirectives: PreprocessedDirectives = value { - DirectivesPreprocessor.preprocess( - content, + DirectivesPreprocessor( Left(relPath.toString), v.scopePath, logger, allowRestrictedFeatures, suppressWarningOptions, maybeRecoverOnError + ).preprocess( + content ) } val s = PreprocessedSource.InMemory( diff --git a/modules/build/src/main/scala/scala/build/preprocessing/ScalaPreprocessor.scala b/modules/build/src/main/scala/scala/build/preprocessing/ScalaPreprocessor.scala index ea4c01e042..a46e12c212 100644 --- a/modules/build/src/main/scala/scala/build/preprocessing/ScalaPreprocessor.scala +++ b/modules/build/src/main/scala/scala/build/preprocessing/ScalaPreprocessor.scala @@ -221,14 +221,15 @@ case object ScalaPreprocessor extends Preprocessor { )(using ScalaCliInvokeData): Either[BuildException, Option[ProcessingOutput]] = either { val (content0, isSheBang) = SheBang.ignoreSheBangLines(content) val preprocessedDirectives: PreprocessedDirectives = - value(DirectivesPreprocessor.preprocess( - extractedDirectives, + value(DirectivesPreprocessor( path, scopeRoot, logger, allowRestrictedFeatures, suppressWarningOptions, maybeRecoverOnError + ).preprocess( + extractedDirectives )) if (preprocessedDirectives.isEmpty) None diff --git a/modules/build/src/test/scala/scala/build/tests/BuildProjectTests.scala b/modules/build/src/test/scala/scala/build/tests/BuildProjectTests.scala index 8d17d60da5..77aa0f6db3 100644 --- a/modules/build/src/test/scala/scala/build/tests/BuildProjectTests.scala +++ b/modules/build/src/test/scala/scala/build/tests/BuildProjectTests.scala @@ -3,19 +3,19 @@ package scala.build.tests import bloop.rifle.BloopRifleLogger import com.eed3si9n.expecty.Expecty.expect import coursier.cache.CacheLogger -import org.scalajs.logging.{Logger => ScalaJsLogger, NullLogger} +import org.scalajs.logging.{NullLogger, Logger as ScalaJsLogger} import java.io.PrintStream - -import scala.build.Ops._ +import scala.build.Ops.* import scala.build.errors.{BuildException, Diagnostic, Severity} import scala.build.input.Inputs +import scala.build.internals.FeatureType import scala.build.options.{ BuildOptions, InternalOptions, JavaOptions, - ScalacOpt, ScalaOptions, + ScalacOpt, Scope, ShadowingSeq } @@ -61,6 +61,8 @@ class BuildProjectTests extends munit.FunSuite { override def verbosity = ??? + override def experimentalWarning(featureName: String, featureType: FeatureType): Unit = ??? + override def flushExperimentalWarnings: Unit = ??? } val bloopJavaPath = Position.Bloop("/home/empty/jvm/8/") diff --git a/modules/build/src/test/scala/scala/build/tests/TestLogger.scala b/modules/build/src/test/scala/scala/build/tests/TestLogger.scala index d8b0153b5d..7a7033b5bf 100644 --- a/modules/build/src/test/scala/scala/build/tests/TestLogger.scala +++ b/modules/build/src/test/scala/scala/build/tests/TestLogger.scala @@ -3,12 +3,13 @@ package scala.build.tests import bloop.rifle.BloopRifleLogger import coursier.cache.CacheLogger import coursier.cache.loggers.{FallbackRefreshDisplay, RefreshLogger} -import org.scalajs.logging.{Logger => ScalaJsLogger, NullLogger} +import org.scalajs.logging.{NullLogger, Logger as ScalaJsLogger} import scala.build.errors.BuildException import scala.build.Logger -import scala.scalanative.{build => sn} +import scala.scalanative.build as sn import scala.build.errors.Diagnostic +import scala.build.internals.FeatureType case class TestLogger(info: Boolean = true, debug: Boolean = false) extends Logger { @@ -83,4 +84,9 @@ case class TestLogger(info: Boolean = true, debug: Boolean = false) extends Logg if (debug) 2 else if (info) 0 else -1 + + override def experimentalWarning(featureName: String, featureType: FeatureType): Unit = + System.err.println(s"Experimental $featureType `$featureName` used") + + override def flushExperimentalWarnings: Unit = () } diff --git a/modules/cli/src/main/scala/scala/cli/commands/RestrictedCommandsParser.scala b/modules/cli/src/main/scala/scala/cli/commands/RestrictedCommandsParser.scala index ddeb694884..09670fabc6 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/RestrictedCommandsParser.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/RestrictedCommandsParser.scala @@ -9,6 +9,7 @@ import caseapp.core.{Arg, Error} import scala.build.Logger import scala.build.input.ScalaCliInvokeData import scala.build.internal.util.WarningMessages +import scala.build.internals.FeatureType import scala.cli.ScalaCli import scala.cli.util.ArgHelpers.* @@ -54,7 +55,7 @@ object RestrictedCommandsParser { )) case (r @ Right(Some(_, arg: Arg, _)), passedOption :: _) if arg.isExperimental && !shouldSuppressExperimentalWarnings => - logger.message(WarningMessages.experimentalOptionUsed(passedOption)) + logger.experimentalWarning(passedOption, FeatureType.Option) r case (other, _) => other diff --git a/modules/cli/src/main/scala/scala/cli/commands/ScalaCommand.scala b/modules/cli/src/main/scala/scala/cli/commands/ScalaCommand.scala index f36e4365c4..50d421cf45 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/ScalaCommand.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/ScalaCommand.scala @@ -19,6 +19,7 @@ import scala.build.errors.BuildException import scala.build.input.{ScalaCliInvokeData, SubCommand} import scala.build.internal.util.WarningMessages import scala.build.internal.{Constants, Runner} +import scala.build.internals.FeatureType import scala.build.options.{BuildOptions, ScalacOpt, Scope} import scala.build.{Artifacts, Directories, Logger, Positioned, ReplArtifacts} import scala.cli.commands.default.LegacyScalaOptions @@ -288,12 +289,12 @@ abstract class ScalaCommand[T <: HasGlobalOptions](implicit myParser: Parser[T], message = s"""${hm.message} | - |${WarningMessages.experimentalSubcommandUsed(name)}""".stripMargin, + |${WarningMessages.experimentalSubcommandWarning(name)}""".stripMargin, detailedMessage = if hm.detailedMessage.nonEmpty then s"""${hm.detailedMessage} | - |${WarningMessages.experimentalSubcommandUsed(name)}""".stripMargin + |${WarningMessages.experimentalSubcommandWarning(name)}""".stripMargin else hm.detailedMessage ) ) @@ -354,13 +355,15 @@ abstract class ScalaCommand[T <: HasGlobalOptions](implicit myParser: Parser[T], )) sys.exit(1) else if isExperimental && !shouldSuppressExperimentalFeatureWarnings then - logger.message(WarningMessages.experimentalSubcommandUsed(name)) + logger.experimentalWarning(name, FeatureType.Subcommand) + maybePrintWarnings(options) maybePrintGroupHelp(options) buildOptions(options).foreach { bo => maybePrintSimpleScalacOutput(options, bo) maybePrintToolsHelp(options, bo) } + logger.flushExperimentalWarnings runCommand(options, remainingArgs, options.global.logging.logger) } } diff --git a/modules/cli/src/main/scala/scala/cli/commands/config/Config.scala b/modules/cli/src/main/scala/scala/cli/commands/config/Config.scala index 87765b34b1..917554d903 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/config/Config.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/config/Config.scala @@ -9,6 +9,7 @@ import java.util.Base64 import scala.build.Ops.* import scala.build.errors.{BuildException, CompositeBuildException, MalformedCliInputError} import scala.build.internal.util.WarningMessages +import scala.build.internals.FeatureType import scala.build.{Directories, Logger} import scala.cli.ScalaCli.allowRestrictedFeatures import scala.cli.commands.pgp.PgpScalaSigningOptions @@ -115,7 +116,7 @@ object Config extends ScalaCommand[ConfigOptions] { sys.exit(1) case Some(entry) => if entry.isExperimental && !shouldSuppressExperimentalFeatureWarnings then - logger.message(WarningMessages.experimentalConfigKeyUsed(entry.fullName)) + logger.experimentalWarning(entry.fullName, FeatureType.ConfigKey) if (values.isEmpty) if (options.unset) { db.remove(entry) @@ -292,6 +293,8 @@ object Config extends ScalaCommand[ConfigOptions] { } } } + + logger.flushExperimentalWarnings } /** Check whether to ask for an update depending on the provided key. diff --git a/modules/cli/src/main/scala/scala/cli/internal/CliLogger.scala b/modules/cli/src/main/scala/scala/cli/internal/CliLogger.scala index a099a006c3..07d2d7e856 100644 --- a/modules/cli/src/main/scala/scala/cli/internal/CliLogger.scala +++ b/modules/cli/src/main/scala/scala/cli/internal/CliLogger.scala @@ -2,7 +2,7 @@ package scala.cli.internal import bloop.rifle.BloopRifleLogger import ch.epfl.scala.bsp4j.Location -import ch.epfl.scala.{bsp4j => b} +import ch.epfl.scala.bsp4j as b import coursier.cache.CacheLogger import coursier.cache.loggers.{FallbackRefreshDisplay, RefreshLogger} import org.scalajs.logging.{Level => ScalaJsLevel, Logger => ScalaJsLogger, ScalaConsoleLogger} @@ -12,10 +12,13 @@ import java.io.PrintStream import scala.build.bsp.protocol.TextEdit import scala.build.errors.{BuildException, CompositeBuildException, Diagnostic, Severity} import scala.build.internal.CustomProgressBarRefreshDisplay +import scala.build.internal.util.WarningMessages +import scala.build.internals.FeatureType +import scala.build.options.ShadowingSeq import scala.build.{ConsoleBloopBuildClient, Logger, Position} import scala.collection.mutable -import scala.jdk.CollectionConverters._ -import scala.scalanative.{build => sn} +import scala.jdk.CollectionConverters.* +import scala.scalanative.build as sn class CliLogger( val verbosity: Int, @@ -131,6 +134,7 @@ class CliLogger( if (verbosity >= 2) printEx(ex, new mutable.HashMap) def exit(ex: BuildException): Nothing = + flushExperimentalWarnings if (verbosity < 0) sys.exit(1) else if (verbosity == 0) { @@ -205,6 +209,30 @@ class CliLogger( // Allow to disable that? def compilerOutputStream = out + + private var experimentalWarnings: Map[FeatureType, Set[String]] = Map.empty + private var reported: Map[FeatureType, Set[String]] = Map.empty + def experimentalWarning(featureName: String, featureType: FeatureType): Unit = + if (!reported.get(featureType).exists(_.contains(featureName))) + experimentalWarnings ++= experimentalWarnings.updatedWith(featureType) { + case None => Some(Set(featureName)) + case Some(namesSet) => Some(namesSet + featureName) + } + def flushExperimentalWarnings: Unit = if (experimentalWarnings.nonEmpty) { + val messageStr: String = { + val namesAndTypes = for { + (featureType, names) <- experimentalWarnings.toSeq.sortBy(_._1) // by feature type + name <- names + } yield name -> featureType + WarningMessages.experimentalFeaturesUsed(namesAndTypes) + } + message(messageStr) + reported = for { + (featureType, names) <- experimentalWarnings + reportedNames = reported.getOrElse(featureType, Set.empty[String]) + } yield featureType -> (names ++ reportedNames) + experimentalWarnings = Map.empty + } } object CliLogger { diff --git a/modules/core/src/main/scala/scala/build/Logger.scala b/modules/core/src/main/scala/scala/build/Logger.scala index 50678b2303..954b00cfde 100644 --- a/modules/core/src/main/scala/scala/build/Logger.scala +++ b/modules/core/src/main/scala/scala/build/Logger.scala @@ -6,6 +6,7 @@ import org.scalajs.logging.{Logger => ScalaJsLogger, NullLogger} import java.io.{OutputStream, PrintStream} import scala.build.errors.{BuildException, Diagnostic, Severity} +import scala.build.internals.FeatureType import scala.scalanative.{build => sn} trait Logger { @@ -37,6 +38,12 @@ trait Logger { def compilerOutputStream: PrintStream def verbosity: Int + + /** Since we have a lot of experimental warnings all over the build process, this method can be + * used to accumulate them for a better UX + */ + def experimentalWarning(featureName: String, featureType: FeatureType): Unit + def flushExperimentalWarnings: Unit } object Logger { @@ -73,6 +80,9 @@ object Logger { ) def verbosity: Int = -1 + + def experimentalWarning(featureUsed: String, featureType: FeatureType): Unit = () + def flushExperimentalWarnings: Unit = () } def nop: Logger = new Nop } diff --git a/modules/core/src/main/scala/scala/build/internals/FeatureType.scala b/modules/core/src/main/scala/scala/build/internals/FeatureType.scala new file mode 100644 index 0000000000..a33973b8be --- /dev/null +++ b/modules/core/src/main/scala/scala/build/internals/FeatureType.scala @@ -0,0 +1,21 @@ +package scala.build.internals + +enum FeatureType(stringRepr: String) { + override def toString: String = stringRepr + + case Option extends FeatureType("option") + case Directive extends FeatureType("directive") + case Subcommand extends FeatureType("sub-command") + case ConfigKey extends FeatureType("configuration key") +} + +object FeatureType { + private val ordering = Map( + FeatureType.Subcommand -> 0, + FeatureType.Option -> 1, + FeatureType.Directive -> 2, + FeatureType.ConfigKey -> 3 + ) + + given Ordering[FeatureType] = Ordering.by(ordering) +} diff --git a/modules/integration/src/test/scala/scala/cli/integration/SipScalaTests.scala b/modules/integration/src/test/scala/scala/cli/integration/SipScalaTests.scala index 262074a9d0..2e9152cd28 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/SipScalaTests.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/SipScalaTests.scala @@ -7,6 +7,15 @@ import scala.util.Properties class SipScalaTests extends ScalaCliSuite { + implicit class StringEnrichment(s: String) { + def containsExperimentalWarningOf(featureNameAndType: String): Boolean = + s.contains(s"The $featureNameAndType is experimental") || + s.linesIterator + .dropWhile(!_.endsWith("are marked as experimental:")) + .takeWhile(_ != "Please bear in mind that non-ideal user experience should be expected.") + .contains(s" - $featureNameAndType") + } + implicit class BinaryNameOps(binaryName: String) { def prepareBinary(root: os.Path): os.Path = { val cliPath = os.Path(TestUtil.cliPath, os.pwd) @@ -53,29 +62,38 @@ class SipScalaTests extends ScalaCliSuite { else expect(res.exitCode == 0) } - def testExportCommand(isPowerMode: Boolean, areWarningsSuppressed: Boolean): Unit = - TestInputs.empty.fromRoot { root => + def testExportCommand(isPowerMode: Boolean, areWarningsSuppressed: Boolean): Unit = { + val inputs = TestInputs( + os.rel / "HelloWorld.scala" -> + """//> using scala 3.0.0 + |object HelloWorld extends App { println(\"Hello World\") } + |""".stripMargin + ) + + inputs.fromRoot { root => val res = os.proc( TestUtil.cli, powerArgs(isPowerMode), "export", - suppressExperimentalWarningArgs(areWarningsSuppressed) + suppressExperimentalWarningArgs(areWarningsSuppressed), + "." ).call( cwd = root, check = false, stderr = os.Pipe ) val errOutput = res.err.trim() - expect(res.exitCode == 1) isPowerMode -> areWarningsSuppressed match { case (false, _) => expect(errOutput.contains("The `export` sub-command is experimental.")) + expect(res.exitCode == 1) case (true, false) => - expect(errOutput.contains("The `export` sub-command is experimental.")) + expect(errOutput.containsExperimentalWarningOf("`export` sub-command")) case (true, true) => - expect(!errOutput.contains("The `export` sub-command is experimental.")) + expect(!errOutput.containsExperimentalWarningOf("`export` sub-command")) } } + } def testConfigCommand(isPowerMode: Boolean, areWarningsSuppressed: Boolean): Unit = TestInputs.empty.fromRoot { root => @@ -101,8 +119,8 @@ class SipScalaTests extends ScalaCliSuite { "The `repositories.default` configuration key is restricted." )) expect(configPublishUserResult.exitCode == 1) - expect(configPublishUserErrOutput.contains( - "The `publish.user.name` configuration key is experimental." + expect(configPublishUserErrOutput.containsExperimentalWarningOf( + "`publish.user.name` configuration key" )) case (true, false) => expect(configProxyResult.exitCode == 0) @@ -110,8 +128,8 @@ class SipScalaTests extends ScalaCliSuite { "The `repositories.default` configuration key is restricted." )) expect(configPublishUserResult.exitCode == 0) - expect(configPublishUserErrOutput.contains( - "The `publish.user.name` configuration key is experimental." + expect(configPublishUserErrOutput.containsExperimentalWarningOf( + "`publish.user.name` configuration key" )) case (true, true) => expect(configProxyResult.exitCode == 0) @@ -120,7 +138,7 @@ class SipScalaTests extends ScalaCliSuite { )) expect(configPublishUserResult.exitCode == 0) expect(!configPublishUserErrOutput.contains( - "The `publish.user.name` configuration key is experimental." + "`publish.user.name` configuration key" )) } } @@ -156,7 +174,7 @@ class SipScalaTests extends ScalaCliSuite { } } - def testPublishDirectives(isPowerMode: Boolean, areWarningsSuppressed: Boolean): Unit = + def testExperimentalDirectives(isPowerMode: Boolean, areWarningsSuppressed: Boolean): Unit = TestInputs.empty.fromRoot { root => val code = """ @@ -181,26 +199,23 @@ class SipScalaTests extends ScalaCliSuite { ) val errOutput = res.err.trim() + isPowerMode -> areWarningsSuppressed match { case (false, _) => expect(res.exitCode == 1) expect(errOutput.contains(s"directive is experimental")) case (true, false) => expect(res.exitCode == 0) - expect(errOutput.contains( - """The `//> using publish.name "my-library"` directive is experimental.""" - )) - expect(errOutput.contains( - """The `//> using python` directive is experimental.""" + expect(errOutput.containsExperimentalWarningOf( + "`//> using publish.name \"my-library\"`" )) + expect(errOutput.containsExperimentalWarningOf("`//> using python`")) case (true, true) => expect(res.exitCode == 0) - expect(!errOutput.contains( - """The `//> using publish.name "my-library"` directive is experimental.""" - )) - expect(!errOutput.contains( - """The `//> using python` directive is experimental.""" + expect(!errOutput.containsExperimentalWarningOf( + "`//> using publish.name \"my-library\"`" )) + expect(!errOutput.containsExperimentalWarningOf("`//> using python`")) } } @@ -232,14 +247,15 @@ class SipScalaTests extends ScalaCliSuite { isPowerMode -> areWarningsSuppressed match { case (false, _) => expect(res.exitCode == 1) - expect(errOutput.contains(s"option is experimental")) - expect(errOutput.contains("--markdown")) + expect( + errOutput.contains("Unrecognized argument: The `--markdown` option is experimental.") + ) case (true, false) => expect(res.exitCode == 0) - expect(errOutput.contains("The `--markdown` option is experimental.")) + expect(errOutput.containsExperimentalWarningOf("`--markdown` option")) case (true, true) => expect(res.exitCode == 0) - expect(!errOutput.contains("The `--markdown` option is experimental.")) + expect(!errOutput.containsExperimentalWarningOf("`--markdown` option")) } } @@ -288,11 +304,11 @@ class SipScalaTests extends ScalaCliSuite { testWhenDisabled = (root, homeEnv) => { val errOutput = callExperimentalFeature(root, homeEnv).err.trim() - expect(errOutput.contains(s"$featureType is experimental.")) + expect(errOutput.containsExperimentalWarningOf(featureType)) }, testWhenEnabled = (root, homeEnv) => { val errOutput = callExperimentalFeature(root, homeEnv).err.trim() - expect(!errOutput.contains(s"$featureType is experimental.")) + expect(!errOutput.containsExperimentalWarningOf(featureType)) } ) } @@ -315,9 +331,9 @@ class SipScalaTests extends ScalaCliSuite { warningsSuppressedString = if (warningsSuppressed) "suppressed" else "not suppressed" } { test( - s"test publish directives when power mode is $powerModeString and experimental warnings are $warningsSuppressedString" + s"test experimental directives when power mode is $powerModeString and experimental warnings are $warningsSuppressedString" ) { - testPublishDirectives(isPowerMode, warningsSuppressed) + testExperimentalDirectives(isPowerMode, warningsSuppressed) } test( s"test markdown options when power mode is $powerModeString and experimental warnings are $warningsSuppressedString" @@ -348,7 +364,7 @@ class SipScalaTests extends ScalaCliSuite { } test("test global config suppressing warnings for an experimental sub-command") { - testConfigSuppressingExperimentalFeatureWarnings("sub-command") { + testConfigSuppressingExperimentalFeatureWarnings("`export` sub-command") { (root: os.Path, homeEnv: Map[String, String]) => val res = os.proc(TestUtil.cli, "--power", "export") .call(cwd = root, check = false, env = homeEnv, stderr = os.Pipe) @@ -357,14 +373,16 @@ class SipScalaTests extends ScalaCliSuite { } } test("test global config suppressing warnings for an experimental option") { - testConfigSuppressingExperimentalFeatureWarnings("option") { + testConfigSuppressingExperimentalFeatureWarnings("`--md` option") { (root: os.Path, homeEnv: Map[String, String]) => os.proc(TestUtil.cli, "--power", "-e", "println()", "--md") .call(cwd = root, env = homeEnv, stderr = os.Pipe) } } test("test global config suppressing warnings for an experimental directive") { - testConfigSuppressingExperimentalFeatureWarnings("directive") { + testConfigSuppressingExperimentalFeatureWarnings( + "`//> using publish.name \"my-library\"` directive" + ) { (root: os.Path, homeEnv: Map[String, String]) => val quote = TestUtil.argQuotationMark os.proc(TestUtil.cli, "--power", "-e", s"//> using publish.name ${quote}my-library$quote") @@ -372,7 +390,7 @@ class SipScalaTests extends ScalaCliSuite { } } test("test global config suppressing warnings for an experimental configuration key") { - testConfigSuppressingExperimentalFeatureWarnings("configuration key") { + testConfigSuppressingExperimentalFeatureWarnings("`publish.user.name` configuration key") { (root: os.Path, homeEnv: Map[String, String]) => os.proc(TestUtil.cli, "--power", "config", "publish.user.name") .call(cwd = root, env = homeEnv, stderr = os.Pipe) @@ -398,4 +416,46 @@ class SipScalaTests extends ScalaCliSuite { } ) } + + test("test multiple sources of experimental features") { + val inputs = TestInputs( + os.rel / "Main.scala" -> + """//> using target.scope main + |//> using target.platform jvm + |//> using publish.name "my-library" + | + |object Main { + | def main(args: Array[String]): Unit = { + | println("Hello World!") + | } + |} + |""".stripMargin + ) + + inputs.fromRoot { root => + val res = os.proc(TestUtil.cli, "--power", "export", ".", "--object-wrapper", "--md") + .call(cwd = root, mergeErrIntoOut = true) + + val output = res.out.trim + + assertNoDiff( + output, + s"""Some utilized features are marked as experimental: + | - `export` sub-command + | - `--object-wrapper` option + | - `--md` option + |Please bear in mind that non-ideal user experience should be expected. + |If you encounter any bugs or have feedback to share, make sure to reach out to the maintenance team at https://github.com/VirtusLab/scala-cli + |Exporting to a sbt project... + |Some utilized directives are marked as experimental: + | - `//> using publish.name "my-library"` + | - `//> using target.scope "main"` + | - `//> using target.platform "jvm"` + |Please bear in mind that non-ideal user experience should be expected. + |If you encounter any bugs or have feedback to share, make sure to reach out to the maintenance team at https://github.com/VirtusLab/scala-cli + |Exported to: ${root / "dest"} + |""".stripMargin + ) + } + } }