From c3e72f79c00dbb7aad40dc7947d3adfd83bf6375 Mon Sep 17 00:00:00 2001 From: Dream <42954461+eureka928@users.noreply.github.com> Date: Sat, 28 Feb 2026 01:52:55 -0500 Subject: [PATCH] [2.x] fix: Backtick-quote project IDs in classloader error messages (#8830) When a test fails with ClassNotFoundException/IllegalAccessError, sbt suggests a set command to change classLoaderLayeringStrategy. If the project name contains hyphens (e.g. bug-report), the suggested command was syntactically invalid because it parses as subtraction in Scala. Quote project IDs using Util.quoteIfNotScalaId so the suggested commands are valid when copy-pasted. Fixes #5803 --- build.sbt | 1 + .../main/scala/sbt/internal/util/Util.scala | 8 +++ .../scala/sbt/internal/util/UtilSpec.scala | 49 +++++++++++++++++++ main/src/main/scala/sbt/Defaults.scala | 17 ++++++- 4 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 internal/util-core/src/test/scala/sbt/internal/util/UtilSpec.scala diff --git a/build.sbt b/build.sbt index 32a07fe9c..c125f8a95 100644 --- a/build.sbt +++ b/build.sbt @@ -311,6 +311,7 @@ lazy val utilCore = project utilCommonSettings, name := "Util Core", Utils.keywordsSettings, + libraryDependencies ++= Seq(hedgehog % Test), mimaSettings, mimaBinaryIssueFilters ++= Seq( ), diff --git a/internal/util-core/src/main/scala/sbt/internal/util/Util.scala b/internal/util-core/src/main/scala/sbt/internal/util/Util.scala index 3b241b27e..768331027 100644 --- a/internal/util-core/src/main/scala/sbt/internal/util/Util.scala +++ b/internal/util-core/src/main/scala/sbt/internal/util/Util.scala @@ -45,6 +45,14 @@ object Util: def quoteIfKeyword(s: String): String = if (ScalaKeywords.values(s)) s"`${s}`" else s + def quoteIfNotScalaId(s: String): String = + if isValidScalaId(s) && !ScalaKeywords.values(s) then s + else s"`$s`" + + private def isValidScalaId(s: String): Boolean = + s.nonEmpty && (s.charAt(0).isLetter || s.charAt(0) == '_') && + s.forall(c => c.isLetterOrDigit || c == '_') + def ignoreResult[A](f: => A): Unit = { val _ = f () diff --git a/internal/util-core/src/test/scala/sbt/internal/util/UtilSpec.scala b/internal/util-core/src/test/scala/sbt/internal/util/UtilSpec.scala new file mode 100644 index 000000000..cd48e3071 --- /dev/null +++ b/internal/util-core/src/test/scala/sbt/internal/util/UtilSpec.scala @@ -0,0 +1,49 @@ +/* + * sbt + * Copyright 2023, Scala center + * Copyright 2011 - 2022, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbt.internal.util + +import hedgehog.* +import hedgehog.runner.* + +object UtilSpec extends Properties: + override def tests: List[Test] = List( + example( + "quoteIfNotScalaId should not quote simple identifiers", + assertQuote("foo", "foo"), + ), + example( + "quoteIfNotScalaId should not quote identifiers with underscores", + assertQuote("foo_bar", "foo_bar"), + ), + example( + "quoteIfNotScalaId should quote identifiers with hyphens", + assertQuote("bug-report", "`bug-report`"), + ), + example( + "quoteIfNotScalaId should quote identifiers with dots", + assertQuote("my.project", "`my.project`"), + ), + example( + "quoteIfNotScalaId should quote identifiers starting with digits", + assertQuote("123abc", "`123abc`"), + ), + example( + "quoteIfNotScalaId should quote Scala keywords", + assertQuote("class", "`class`"), + ), + example( + "quoteIfNotScalaId should quote empty strings", + assertQuote("", "``"), + ), + ) + + private def assertQuote(input: String, expected: String): Result = + val actual = Util.quoteIfNotScalaId(input) + Result.assert(actual == expected).log(s"input=$input expected=$expected actual=$actual") +end UtilSpec diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index 6aa5a2f56..c517e39cd 100644 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -1201,7 +1201,20 @@ object Defaults extends BuildCommon { thisProject, fileConverter, ).flatMapN { (s, lt, tl, gp, ex, cp, fp, jo, clls, thisProj, c) => - allTestGroupsTask(s, lt, tl, gp, ex, cp, fp, fpm, jo, clls, s"${thisProj.id} / ", c) + allTestGroupsTask( + s, + lt, + tl, + gp, + ex, + cp, + fp, + fpm, + jo, + clls, + s"${Util.quoteIfNotScalaId(thisProj.id)} / ", + c + ) } }.value), // ((streams in test, loadedTestFrameworks, testLoader, testGrouping in test, testExecution in test, fullClasspath in test, javaHome in test, testForkedParallel, javaOptions in test) flatMap allTestGroupsTask).value, @@ -1377,7 +1390,7 @@ object Defaults extends BuildCommon { testForkedParallelism.value, javaOptions.value, classLoaderLayeringStrategy.value, - projectId = s"${thisProject.value.id} / ", + projectId = s"${Util.quoteIfNotScalaId(thisProject.value.id)} / ", converter = fileConverter.value, ) val taskName = display.show(resolvedScoped.value)