From bcf486211f5a5685cb437d6e347d65926222fb47 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Tue, 26 May 2026 10:09:00 +0200 Subject: [PATCH 1/3] Remove dead code in ScriptPreprocessor --- .../scala/scala/build/preprocessing/ScriptPreprocessor.scala | 1 - 1 file changed, 1 deletion(-) diff --git a/modules/build/src/main/scala/scala/build/preprocessing/ScriptPreprocessor.scala b/modules/build/src/main/scala/scala/build/preprocessing/ScriptPreprocessor.scala index 9a17792868..7afd52ecba 100644 --- a/modules/build/src/main/scala/scala/build/preprocessing/ScriptPreprocessor.scala +++ b/modules/build/src/main/scala/scala/build/preprocessing/ScriptPreprocessor.scala @@ -105,7 +105,6 @@ case object ScriptPreprocessor extends Preprocessor { inputArgPath.getOrElse(subPath.toString) ) - (pkg :+ wrapper).map(_.raw).mkString(".") val relPath = os.rel / (subPath / os.up) / s"${subPath.last.stripSuffix(".sc")}.scala" val file = PreprocessedSource.UnwrappedScript( From 86a924b47fdcaf2e32f07c11cc53d00492ce2bec Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Tue, 26 May 2026 11:02:38 +0200 Subject: [PATCH 2/3] Add a diagnostic for when a script's wrapper name shadows a dependency package --- .../src/main/scala/scala/build/Build.scala | 15 +++++- .../src/main/scala/scala/build/Sources.scala | 11 +++- .../scala/scala/build/internal/JarUtils.scala | 32 ++++++++++++ .../scala/build/internal/MainClass.scala | 21 +------- .../scala/build/internal/ScriptUtils.scala | 41 +++++++++++++++ .../build/internal/util/WarningMessages.scala | 12 +++++ .../RunScriptTestDefinitions.scala | 50 +++++++++++++++++++ 7 files changed, 161 insertions(+), 21 deletions(-) create mode 100644 modules/build/src/main/scala/scala/build/internal/JarUtils.scala create mode 100644 modules/build/src/main/scala/scala/build/internal/ScriptUtils.scala diff --git a/modules/build/src/main/scala/scala/build/Build.scala b/modules/build/src/main/scala/scala/build/Build.scala index f868b60dd6..cb0ae718c8 100644 --- a/modules/build/src/main/scala/scala/build/Build.scala +++ b/modules/build/src/main/scala/scala/build/Build.scala @@ -16,7 +16,8 @@ import scala.build.compiler.{ScalaCompiler, ScalaCompilerMaker} import scala.build.errors.* import scala.build.input.* import scala.build.internal.resource.ResourceMapper -import scala.build.internal.{Constants, MainClass, Name, Util} +import scala.build.internal.util.WarningMessages +import scala.build.internal.{Constants, MainClass, Name, ScriptUtils, Util} import scala.build.internals.ConsoleUtils.ScalaCliConsole.warnPrefix import scala.build.options.* import scala.build.options.validation.ValidationException @@ -1129,6 +1130,18 @@ object Build { val artifacts = value(options0.artifacts(logger, scope, maybeRecoverOnError)) + for shadowed <- ScriptUtils.findShadowedDependencyPackages( + sources = sources, + classPath = artifacts.compileClassPath, + logger = logger + ) + do + logger.diagnostic( + message = WarningMessages.scriptNameShadowsDependencyPackage(shadowed), + severity = Severity.Warning, + positions = Seq(Position.File(Right(shadowed.filePath), (0, 0), (0, 0))) + ) + value(validate(logger, options0)) val project = value { diff --git a/modules/build/src/main/scala/scala/build/Sources.scala b/modules/build/src/main/scala/scala/build/Sources.scala index a9fd4c196d..4c2fb5092a 100644 --- a/modules/build/src/main/scala/scala/build/Sources.scala +++ b/modules/build/src/main/scala/scala/build/Sources.scala @@ -6,7 +6,8 @@ import coursier.util.Task import java.nio.charset.StandardCharsets import scala.build.input.Inputs -import scala.build.internal.{CodeWrapper, WrapperParams} +import scala.build.internal.ScriptUtils.ScriptDescriptor +import scala.build.internal.{AmmUtil, CodeWrapper, WrapperParams} import scala.build.options.{BuildOptions, Scope} import scala.build.preprocessing.* @@ -76,6 +77,14 @@ final case class Sources( lazy val hasScala = (paths.iterator.map(_._1.last) ++ inMemory.iterator.map(_.generatedRelPath.last)) .exists(_.endsWith(".scala")) + + def scriptTopLevelNames: Seq[ScriptDescriptor] = + inMemory.collect { + case Sources.InMemory(Right((subPath, filePath)), _, _, Some(_)) => + val (pkg, wrapper) = AmmUtil.pathToPackageWrapper(subPath) + val topLevelName = pkg.headOption.map(_.raw).getOrElse(wrapper.raw) + ScriptDescriptor(topLevelName, subPath, filePath) + } } object Sources { diff --git a/modules/build/src/main/scala/scala/build/internal/JarUtils.scala b/modules/build/src/main/scala/scala/build/internal/JarUtils.scala new file mode 100644 index 0000000000..9e88ea3dae --- /dev/null +++ b/modules/build/src/main/scala/scala/build/internal/JarUtils.scala @@ -0,0 +1,32 @@ +package scala.build.internal + +import java.io.ByteArrayInputStream +import java.nio.file.NoSuchFileException + +import scala.build.internal.zip.WrappedZipInputStream +import scala.build.{Logger, retry} + +object JarUtils { + + /** Walk `.class` entries in a JAR */ + def walkClassEntries[A](jar: os.Path, logger: Logger)( + extract: (String, () => Array[Byte]) => Iterator[A] + ): Iterator[A] = + try + retry()(logger) { + val content = os.read.bytes(jar) + val zip = WrappedZipInputStream.create(new ByteArrayInputStream(content)) + zip.entries().flatMap { ent => + if !ent.isDirectory && ent.getName.endsWith(".class") then + extract(ent.getName, () => zip.readAllBytes()) + else Iterator.empty + } + } + catch { + case e: NoSuchFileException => + logger.debugStackTrace(e) + logger.debug(s"JAR file $jar not found: $e, skipping.") + Iterator.empty + } + +} diff --git a/modules/build/src/main/scala/scala/build/internal/MainClass.scala b/modules/build/src/main/scala/scala/build/internal/MainClass.scala index 33bd5067cf..4c694082d5 100644 --- a/modules/build/src/main/scala/scala/build/internal/MainClass.scala +++ b/modules/build/src/main/scala/scala/build/internal/MainClass.scala @@ -7,7 +7,6 @@ import java.io.{ByteArrayInputStream, InputStream} import java.nio.file.NoSuchFileException import java.util.jar.{Attributes, JarFile} -import scala.build.internal.zip.WrappedZipInputStream import scala.build.{Logger, retry} object MainClass { @@ -73,24 +72,8 @@ object MainClass { finally is.close() private def findInJar(path: os.Path, logger: Logger): Iterator[String] = - try retry()(logger) { - val content = os.read.bytes(path) - val jarInputStream = WrappedZipInputStream.create(new ByteArrayInputStream(content)) - jarInputStream.entries().flatMap(ent => - if !ent.isDirectory && ent.getName.endsWith(".class") then { - val content = jarInputStream.readAllBytes() - val inputStream = new ByteArrayInputStream(content) - findInClass(inputStream, logger) - } - else Iterator.empty - ) - } - catch { - case e: NoSuchFileException => - logger.debugStackTrace(e) - logger.log(s"JAR file $path not found: $e, trying to recover...") - logger.log("Are you trying to run too many builds at once? Trying to recover...") - Iterator.empty + JarUtils.walkClassEntries(path, logger) { (_, bytes) => + findInClass(new ByteArrayInputStream(bytes()), logger) } def findInDependency(jar: os.Path): Option[String] = diff --git a/modules/build/src/main/scala/scala/build/internal/ScriptUtils.scala b/modules/build/src/main/scala/scala/build/internal/ScriptUtils.scala new file mode 100644 index 0000000000..5741277548 --- /dev/null +++ b/modules/build/src/main/scala/scala/build/internal/ScriptUtils.scala @@ -0,0 +1,41 @@ +package scala.build.internal + +import scala.build.{Logger, Sources} + +object ScriptUtils { + private val ignoredPackageRoots = Set("scala", "java", "javax", "META-INF") + + final case class ScriptDescriptor( + name: String, + subPath: os.SubPath, + filePath: os.Path, + shadowedDependencyJars: Seq[String] = Nil + ) + + def findShadowedDependencyPackages( + sources: Sources, + classPath: Seq[os.Path], + logger: Logger + ): Seq[ScriptDescriptor] = { + val scripts = sources.scriptTopLevelNames.filterNot(s => ignoredPackageRoots(s.name)) + if scripts.isEmpty then Nil + else + val packageRoots = + classPath + .filter(_.last.endsWith(".jar")) + .flatMap(path => + JarUtils.walkClassEntries(path, logger) { (name, _) => + val slashIdx = name.indexOf('/') + if slashIdx > 0 then Iterator.single(name.take(slashIdx)) + else Iterator.empty + }.toSet.map(_ -> path) + ) + .groupMap((root, _) => root)((_, path) => path) + .view.mapValues(_.toSet).toMap + scripts.flatMap { script => + packageRoots.get(script.name).map { jars => + script.copy(shadowedDependencyJars = jars.toSeq.map(_.last).sorted) + } + } + } +} 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 996a9bb241..1338c3c80f 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,6 +2,7 @@ package scala.build.internal.util import scala.build.input.ScalaCliInvokeData import scala.build.internal.Constants +import scala.build.internal.ScriptUtils.ScriptDescriptor import scala.build.internals.FeatureType import scala.build.preprocessing.directives.{DirectiveHandler, ScopedDirective} import scala.cli.commands.SpecificationLevel @@ -130,6 +131,17 @@ object WarningMessages { val mainScriptNameClashesWithAppWrapper = "Script file named 'main.sc' detected, keep in mind that accessing it from other scripts is impossible due to a clash of `main` symbols" + def scriptNameShadowsDependencyPackage(shadowed: ScriptDescriptor): String = + val name = shadowed.name + val depsClause = shadowed.shadowedDependencyJars match { + case Nil => "from an unknown dependency" + case Seq(j) => s"from dependency '$j'" + case js => s"from dependencies: ${js.map(j => s"'$j'").mkString(", ")}" + } + s"""Script '${shadowed.subPath}' generates a top-level symbol '$name' that shadows the '$name' package $depsClause. + |Imports of '$name.*' from the dependency will not resolve. + |Consider renaming the script (e.g. to '${name}1.sc') or moving it into a subdirectory.""".stripMargin + private val deprecationNote = "Deprecated features may be removed in a future version." diff --git a/modules/integration/src/test/scala/scala/cli/integration/RunScriptTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/RunScriptTestDefinitions.scala index 67b8d6d5d5..a5f7d6acdd 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/RunScriptTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/RunScriptTestDefinitions.scala @@ -270,6 +270,56 @@ trait RunScriptTestDefinitions { this: RunTestDefinitions => } } + test("warn when script name shadows a dependency top-level package") { + val inputs = TestInputs( + os.rel / "os.sc" -> + s"""//> using dep com.lihaoyi::os-lib:0.11.8 + |println("hi") + |""".stripMargin + ) + inputs.fromRoot { root => + val res = os.proc(TestUtil.cli, extraOptions, "os.sc") + .call(cwd = root, check = false, mergeErrIntoOut = true) + val output = res.out.trim() + expect(output.contains("shadows the 'os' package")) + expect(res.exitCode == 0) + } + } + + test("warn with multiple JARs when script name shadows a shared package root") { + val inputs = TestInputs( + os.rel / "cats.sc" -> + s"""//> using dep org.typelevel::cats-core:2.12.0 + |println("hi") + |""".stripMargin + ) + inputs.fromRoot { root => + val res = os.proc(TestUtil.cli, extraOptions, "cats.sc") + .call(cwd = root, check = false, mergeErrIntoOut = true) + val output = res.out.trim() + expect(output.contains("shadows the 'cats' package from dependencies:")) + expect(output.contains("cats-core")) + expect(output.contains("cats-kernel")) + expect(res.exitCode == 0) + } + } + + test("do not warn when script name does not shadow a dependency top-level package") { + val inputs = TestInputs( + os.rel / "safe.sc" -> + s"""//> using dep com.lihaoyi::os-lib:0.11.8 + |println("hi") + |""".stripMargin + ) + inputs.fromRoot { root => + val res = os.proc(TestUtil.cli, extraOptions, "safe.sc") + .call(cwd = root, check = false, mergeErrIntoOut = true) + val output = res.out.trim() + expect(!output.contains("shadows the 'os' package")) + expect(res.exitCode == 0) + } + } + test("Directory") { val message = "Hello" val inputs = TestInputs( From af1e50bd6336dc3384926e1ffe361c00881d60c6 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Tue, 9 Jun 2026 16:40:21 +0200 Subject: [PATCH 3/3] Verify clashes post-error --- .../scala/scala/build/BloopBuildClient.scala | 2 + .../src/main/scala/scala/build/Build.scala | 32 +++++++---- .../scala/build/ConsoleBloopBuildClient.scala | 24 ++++++++ .../scala/scala/build/bsp/BspClient.scala | 2 + .../main/scala/scala/build/bsp/BspImpl.scala | 4 ++ .../scala/build/internal/ScriptUtils.scala | 54 ++++++++++++------ .../build/internal/util/WarningMessages.scala | 23 +++++--- .../RunScriptTestDefinitions.scala | 55 ++++++++++++++++--- 8 files changed, 152 insertions(+), 44 deletions(-) diff --git a/modules/build/src/main/scala/scala/build/BloopBuildClient.scala b/modules/build/src/main/scala/scala/build/BloopBuildClient.scala index f38bc568d1..261fbf4c62 100644 --- a/modules/build/src/main/scala/scala/build/BloopBuildClient.scala +++ b/modules/build/src/main/scala/scala/build/BloopBuildClient.scala @@ -7,6 +7,8 @@ import scala.build.options.Scope trait BloopBuildClient extends bsp4j.BuildClient { def setProjectParams(newParams: Seq[String]): Unit def setGeneratedSources(scope: Scope, newGeneratedSources: Seq[GeneratedSource]): Unit + def setSuspectedClashNames(names: Set[String]): Unit + def detectedShadowingCandidates: Set[String] def diagnostics: Option[Seq[(Either[String, os.Path], bsp4j.Diagnostic)]] def clear(): Unit } diff --git a/modules/build/src/main/scala/scala/build/Build.scala b/modules/build/src/main/scala/scala/build/Build.scala index cb0ae718c8..d5ae332e0c 100644 --- a/modules/build/src/main/scala/scala/build/Build.scala +++ b/modules/build/src/main/scala/scala/build/Build.scala @@ -1130,18 +1130,6 @@ object Build { val artifacts = value(options0.artifacts(logger, scope, maybeRecoverOnError)) - for shadowed <- ScriptUtils.findShadowedDependencyPackages( - sources = sources, - classPath = artifacts.compileClassPath, - logger = logger - ) - do - logger.diagnostic( - message = WarningMessages.scriptNameShadowsDependencyPackage(shadowed), - severity = Severity.Warning, - positions = Seq(Position.File(Right(shadowed.filePath), (0, 0), (0, 0))) - ) - value(validate(logger, options0)) val project = value { @@ -1236,12 +1224,32 @@ object Build { buildClient.clear() buildClient.setGeneratedSources(scope, generatedSources) + val scriptNames = sources.scriptTopLevelNames.iterator.map(_.name).toSet + buildClient.setSuspectedClashNames(scriptNames) + val partial = partialOpt.getOrElse { options.notForBloopOptions.packageOptions.packageTypeOpt.exists(_.sourceBased) } val success = partial || compiler.compile(project, logger) + if !success && scriptNames.nonEmpty then + val matched = buildClient.detectedShadowingCandidates + if matched.nonEmpty then + for + shadowed <- ScriptUtils.findShadowingClashes( + sources = sources, + classPath = artifacts.compileClassPath, + logger = logger + ) + if matched(shadowed.name) + do + logger.diagnostic( + message = WarningMessages.scriptShadowingClashHint(shadowed), + severity = Severity.Error, + positions = Seq(Position.File(Right(shadowed.filePath), (0, 0), (0, 0))) + ) + if success then Successful( inputs = inputs, diff --git a/modules/build/src/main/scala/scala/build/ConsoleBloopBuildClient.scala b/modules/build/src/main/scala/scala/build/ConsoleBloopBuildClient.scala index 0d492db50e..212045fe99 100644 --- a/modules/build/src/main/scala/scala/build/ConsoleBloopBuildClient.scala +++ b/modules/build/src/main/scala/scala/build/ConsoleBloopBuildClient.scala @@ -5,6 +5,7 @@ import ch.epfl.scala.bsp4j import java.io.File import java.net.URI import java.nio.file.{NoSuchFileException, Paths} +import java.util.concurrent.atomic.AtomicReference import scala.build.errors.Severity import scala.build.internal.WrapperParams @@ -32,6 +33,9 @@ class ConsoleBloopBuildClient( private val diagnostics0 = new mutable.ListBuffer[(Either[String, os.Path], bsp4j.Diagnostic)] + private val suspectedClashNames = new AtomicReference(Set.empty[String]) + private val matchedClashNames = new AtomicReference(Set.empty[String]) + def setGeneratedSources(scope: Scope, newGeneratedSources: Seq[GeneratedSource]) = generatedSources(scope) = newGeneratedSources def setProjectParams(newParams: Seq[String]): Unit = { @@ -41,6 +45,20 @@ class ConsoleBloopBuildClient( if (keepDiagnostics) Some(diagnostics0.result()) else None + def setSuspectedClashNames(names: Set[String]): Unit = + suspectedClashNames.set(names) + + def detectedShadowingCandidates: Set[String] = + matchedClashNames.get() + + private def namesMentionedInMessage(message: String, names: Set[String]): Set[String] = + names.filter { name => + message.contains(name) || + message.contains(s"$name$$_") || + message.contains(s"object $name") || + message.contains(s"`$name`") + } + private def postProcessDiagnostic( path: os.Path, diag: bsp4j.Diagnostic, @@ -81,6 +99,10 @@ class ConsoleBloopBuildClient( val path = os.Path(Paths.get(new URI(params.getTextDocument.getUri)).toAbsolutePath) val (updatedPath, updatedDiag) = postProcessDiagnostic(path, diag, diagnosticMappings) .getOrElse((Right(path), diag)) + if updatedDiag.getSeverity == bsp4j.DiagnosticSeverity.ERROR then + val mentioned = namesMentionedInMessage(updatedDiag.getMessage, suspectedClashNames.get()) + if mentioned.nonEmpty then + matchedClashNames.updateAndGet(_ ++ mentioned) if (keepDiagnostics) diagnostics0 += updatedPath -> updatedDiag ConsoleBloopBuildClient.printFileDiagnostic(logger, updatedPath, updatedDiag) @@ -137,6 +159,8 @@ class ConsoleBloopBuildClient( def clear(): Unit = { generatedSources.clear() diagnostics0.clear() + suspectedClashNames.set(Set.empty) + matchedClashNames.set(Set.empty) printedStart = false } } diff --git a/modules/build/src/main/scala/scala/build/bsp/BspClient.scala b/modules/build/src/main/scala/scala/build/bsp/BspClient.scala index b62c15935b..d9759484c3 100644 --- a/modules/build/src/main/scala/scala/build/bsp/BspClient.scala +++ b/modules/build/src/main/scala/scala/build/bsp/BspClient.scala @@ -115,6 +115,8 @@ class BspClient( } def setProjectParams(newParams: Seq[String]): Unit = {} + def setSuspectedClashNames(names: Set[String]): Unit = {} + def detectedShadowingCandidates: Set[String] = Set.empty def diagnostics: Option[Seq[(Either[String, os.Path], b.Diagnostic)]] = None def clear(): Unit = {} diff --git a/modules/build/src/main/scala/scala/build/bsp/BspImpl.scala b/modules/build/src/main/scala/scala/build/bsp/BspImpl.scala index 1c6cc128f5..6a2f52744f 100644 --- a/modules/build/src/main/scala/scala/build/bsp/BspImpl.scala +++ b/modules/build/src/main/scala/scala/build/bsp/BspImpl.scala @@ -720,6 +720,10 @@ object BspImpl { underlying.setProjectParams(newParams) def setGeneratedSources(scope: Scope, newGeneratedSources: Seq[GeneratedSource]) = underlying.setGeneratedSources(scope, newGeneratedSources) + def setSuspectedClashNames(names: Set[String]) = + underlying.setSuspectedClashNames(names) + def detectedShadowingCandidates = + underlying.detectedShadowingCandidates } private final case class PreBuildData( diff --git a/modules/build/src/main/scala/scala/build/internal/ScriptUtils.scala b/modules/build/src/main/scala/scala/build/internal/ScriptUtils.scala index 5741277548..2a5b040f06 100644 --- a/modules/build/src/main/scala/scala/build/internal/ScriptUtils.scala +++ b/modules/build/src/main/scala/scala/build/internal/ScriptUtils.scala @@ -9,10 +9,11 @@ object ScriptUtils { name: String, subPath: os.SubPath, filePath: os.Path, - shadowedDependencyJars: Seq[String] = Nil + shadowedDependencyJars: Seq[String] = Nil, + clashingLocalSources: Seq[os.SubPath] = Nil ) - def findShadowedDependencyPackages( + def findShadowingClashes( sources: Sources, classPath: Seq[os.Path], logger: Logger @@ -20,22 +21,43 @@ object ScriptUtils { val scripts = sources.scriptTopLevelNames.filterNot(s => ignoredPackageRoots(s.name)) if scripts.isEmpty then Nil else - val packageRoots = - classPath - .filter(_.last.endsWith(".jar")) - .flatMap(path => - JarUtils.walkClassEntries(path, logger) { (name, _) => - val slashIdx = name.indexOf('/') - if slashIdx > 0 then Iterator.single(name.take(slashIdx)) - else Iterator.empty - }.toSet.map(_ -> path) - ) - .groupMap((root, _) => root)((_, path) => path) - .view.mapValues(_.toSet).toMap + val localCandidates = localTopLevelCandidates(sources) + val packageRoots = topLevelPackageRoots(classPath, logger) scripts.flatMap { script => - packageRoots.get(script.name).map { jars => - script.copy(shadowedDependencyJars = jars.toSeq.map(_.last).sorted) + val deps = packageRoots.getOrElse(script.name, Set.empty).toSeq.map(_.last).sorted + val locals = localCandidates.getOrElse(script.name, Nil).filterNot(_ == script.subPath) + Option.when(deps.nonEmpty || locals.nonEmpty) { + script.copy(shadowedDependencyJars = deps, clashingLocalSources = locals) } } } + + private def localTopLevelCandidates(sources: Sources): Map[String, Seq[os.SubPath]] = + (sources.paths.map((_, rel) => (baseName(rel.last), os.SubPath(rel.segments.toIndexedSeq))) ++ + sources.inMemory.collect { + case Sources.InMemory(Right((subPath, _)), _, _, Some(_)) => + (baseName(subPath.last), subPath) + }) + .groupMap(_._1)(_._2) + + private def baseName(fileName: String): String = + val dot = fileName.lastIndexOf('.') + if dot > 0 then fileName.take(dot) else fileName + + private def topLevelPackageRoots( + classPath: Seq[os.Path], + logger: Logger + ): Map[String, Set[os.Path]] = + classPath + .filter(_.last.endsWith(".jar")) + .flatMap(path => + JarUtils.walkClassEntries(path, logger) { (name, _) => + val slashIdx = name.indexOf('/') + if slashIdx > 0 then Iterator.single(name.take(slashIdx)) + else Iterator.empty + }.toSet.map(_ -> path) + ) + .groupMap((root, _) => root)((_, path) => path) + .view.mapValues(_.toSet).toMap + } 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 1338c3c80f..84f37a1774 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 @@ -131,15 +131,22 @@ object WarningMessages { val mainScriptNameClashesWithAppWrapper = "Script file named 'main.sc' detected, keep in mind that accessing it from other scripts is impossible due to a clash of `main` symbols" - def scriptNameShadowsDependencyPackage(shadowed: ScriptDescriptor): String = - val name = shadowed.name - val depsClause = shadowed.shadowedDependencyJars match { - case Nil => "from an unknown dependency" - case Seq(j) => s"from dependency '$j'" - case js => s"from dependencies: ${js.map(j => s"'$j'").mkString(", ")}" + def scriptShadowingClashHint(shadowed: ScriptDescriptor): String = + val name = shadowed.name + val depClause = shadowed.shadowedDependencyJars match { + case Nil => None + case Seq(j) => Some(s"the '$name' package from dependency '$j'") + case js => + Some(s"the '$name' package from dependencies: ${js.map(j => s"'$j'").mkString(", ")}") } - s"""Script '${shadowed.subPath}' generates a top-level symbol '$name' that shadows the '$name' package $depsClause. - |Imports of '$name.*' from the dependency will not resolve. + val localClause = shadowed.clashingLocalSources match { + case Nil => None + case Seq(p) => Some(s"a local source '$p'") + case ps => Some(s"local sources: ${ps.map(p => s"'$p'").mkString(", ")}") + } + val clashes = Seq(depClause, localClause).flatten.mkString(" and ") + s"""Script '${shadowed.subPath}' generates a top-level symbol '$name' that shadows $clashes. + |This is likely the cause of compilation errors mentioning '$name'. |Consider renaming the script (e.g. to '${name}1.sc') or moving it into a subdirectory.""".stripMargin private val deprecationNote = diff --git a/modules/integration/src/test/scala/scala/cli/integration/RunScriptTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/RunScriptTestDefinitions.scala index a5f7d6acdd..f09614fe08 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/RunScriptTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/RunScriptTestDefinitions.scala @@ -270,37 +270,75 @@ trait RunScriptTestDefinitions { this: RunTestDefinitions => } } - test("warn when script name shadows a dependency top-level package") { + test("hint when script name shadows a dependency top-level package and import fails") { val inputs = TestInputs( os.rel / "os.sc" -> s"""//> using dep com.lihaoyi::os-lib:0.11.8 - |println("hi") + |import os.Path + |println(Path("/tmp")) |""".stripMargin ) inputs.fromRoot { root => val res = os.proc(TestUtil.cli, extraOptions, "os.sc") .call(cwd = root, check = false, mergeErrIntoOut = true) val output = res.out.trim() - expect(output.contains("shadows the 'os' package")) - expect(res.exitCode == 0) + expect(res.exitCode != 0) + expect(output.contains("shadows the 'os' package from dependencies:")) + expect(output.contains("os-lib")) + expect(output.contains("is likely the cause of compilation errors mentioning 'os'")) } } - test("warn with multiple JARs when script name shadows a shared package root") { + test("hint with multiple JARs when script name shadows a shared package root") { val inputs = TestInputs( os.rel / "cats.sc" -> s"""//> using dep org.typelevel::cats-core:2.12.0 - |println("hi") + |import cats.Show + |println(Show[Int]) |""".stripMargin ) inputs.fromRoot { root => val res = os.proc(TestUtil.cli, extraOptions, "cats.sc") .call(cwd = root, check = false, mergeErrIntoOut = true) val output = res.out.trim() + expect(res.exitCode != 0) expect(output.contains("shadows the 'cats' package from dependencies:")) expect(output.contains("cats-core")) expect(output.contains("cats-kernel")) - expect(res.exitCode == 0) + } + } + + test("hint when script name clashes with a sibling .scala file") { + val inputs = TestInputs( + os.rel / "foo.sc" -> + """println("hi")""", + os.rel / "foo.scala" -> + """object foo { def hello = "world" }""" + ) + inputs.fromRoot { root => + val res = os.proc(TestUtil.cli, extraOptions, "foo.sc", "foo.scala") + .call(cwd = root, check = false, mergeErrIntoOut = true) + val output = res.out.trim() + expect(res.exitCode != 0) + expect(output.contains("foo.sc")) + expect(output.contains("local source 'foo.scala'") || output.contains("local sources:")) + } + } + + test("do not hint when compile fails for an unrelated reason") { + val inputs = TestInputs( + os.rel / "safe.sc" -> + s"""//> using dep com.lihaoyi::os-lib:0.11.8 + |val x: Int = "not an int" + |""".stripMargin + ) + inputs.fromRoot { root => + val res = os.proc(TestUtil.cli, extraOptions, "safe.sc") + .call(cwd = root, check = false, mergeErrIntoOut = true) + val output = res.out.trim() + expect(res.exitCode != 0) + expect(!output.contains("shadows")) + expect(!output.contains("is likely the cause")) } } @@ -315,7 +353,8 @@ trait RunScriptTestDefinitions { this: RunTestDefinitions => val res = os.proc(TestUtil.cli, extraOptions, "safe.sc") .call(cwd = root, check = false, mergeErrIntoOut = true) val output = res.out.trim() - expect(!output.contains("shadows the 'os' package")) + expect(!output.contains("shadows")) + expect(!output.contains("is likely the cause")) expect(res.exitCode == 0) } }