Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
23 changes: 22 additions & 1 deletion modules/build/src/main/scala/scala/build/Build.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1223,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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = {
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -137,6 +159,8 @@ class ConsoleBloopBuildClient(
def clear(): Unit = {
generatedSources.clear()
diagnostics0.clear()
suspectedClashNames.set(Set.empty)
matchedClashNames.set(Set.empty)
printedStart = false
}
}
Expand Down
11 changes: 10 additions & 1 deletion modules/build/src/main/scala/scala/build/Sources.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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.*

Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions modules/build/src/main/scala/scala/build/bsp/BspClient.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}

Expand Down
4 changes: 4 additions & 0 deletions modules/build/src/main/scala/scala/build/bsp/BspImpl.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
32 changes: 32 additions & 0 deletions modules/build/src/main/scala/scala/build/internal/JarUtils.scala
Original file line number Diff line number Diff line change
@@ -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
}

}
21 changes: 2 additions & 19 deletions modules/build/src/main/scala/scala/build/internal/MainClass.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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] =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
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,
clashingLocalSources: Seq[os.SubPath] = Nil
)

def findShadowingClashes(
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 localCandidates = localTopLevelCandidates(sources)
val packageRoots = topLevelPackageRoots(classPath, logger)
scripts.flatMap { script =>
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

}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -130,6 +131,24 @@ 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 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(", ")}")
}
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 =
"Deprecated features may be removed in a future version."

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading