Skip to content

Commit 7543bbe

Browse files
committed
Verify clashes post-error
1 parent 1e29b35 commit 7543bbe

8 files changed

Lines changed: 152 additions & 44 deletions

File tree

modules/build/src/main/scala/scala/build/BloopBuildClient.scala

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import scala.build.options.Scope
77
trait BloopBuildClient extends bsp4j.BuildClient {
88
def setProjectParams(newParams: Seq[String]): Unit
99
def setGeneratedSources(scope: Scope, newGeneratedSources: Seq[GeneratedSource]): Unit
10+
def setSuspectedClashNames(names: Set[String]): Unit
11+
def detectedShadowingCandidates: Set[String]
1012
def diagnostics: Option[Seq[(Either[String, os.Path], bsp4j.Diagnostic)]]
1113
def clear(): Unit
1214
}

modules/build/src/main/scala/scala/build/Build.scala

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1130,18 +1130,6 @@ object Build {
11301130

11311131
val artifacts = value(options0.artifacts(logger, scope, maybeRecoverOnError))
11321132

1133-
for shadowed <- ScriptUtils.findShadowedDependencyPackages(
1134-
sources = sources,
1135-
classPath = artifacts.compileClassPath,
1136-
logger = logger
1137-
)
1138-
do
1139-
logger.diagnostic(
1140-
message = WarningMessages.scriptNameShadowsDependencyPackage(shadowed),
1141-
severity = Severity.Warning,
1142-
positions = Seq(Position.File(Right(shadowed.filePath), (0, 0), (0, 0)))
1143-
)
1144-
11451133
value(validate(logger, options0))
11461134

11471135
val project = value {
@@ -1236,12 +1224,32 @@ object Build {
12361224
buildClient.clear()
12371225
buildClient.setGeneratedSources(scope, generatedSources)
12381226

1227+
val scriptNames = sources.scriptTopLevelNames.iterator.map(_.name).toSet
1228+
buildClient.setSuspectedClashNames(scriptNames)
1229+
12391230
val partial = partialOpt.getOrElse {
12401231
options.notForBloopOptions.packageOptions.packageTypeOpt.exists(_.sourceBased)
12411232
}
12421233

12431234
val success = partial || compiler.compile(project, logger)
12441235

1236+
if !success && scriptNames.nonEmpty then
1237+
val matched = buildClient.detectedShadowingCandidates
1238+
if matched.nonEmpty then
1239+
for
1240+
shadowed <- ScriptUtils.findShadowingClashes(
1241+
sources = sources,
1242+
classPath = artifacts.compileClassPath,
1243+
logger = logger
1244+
)
1245+
if matched(shadowed.name)
1246+
do
1247+
logger.diagnostic(
1248+
message = WarningMessages.scriptShadowingClashHint(shadowed),
1249+
severity = Severity.Error,
1250+
positions = Seq(Position.File(Right(shadowed.filePath), (0, 0), (0, 0)))
1251+
)
1252+
12451253
if success then
12461254
Successful(
12471255
inputs = inputs,

modules/build/src/main/scala/scala/build/ConsoleBloopBuildClient.scala

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import ch.epfl.scala.bsp4j
55
import java.io.File
66
import java.net.URI
77
import java.nio.file.{NoSuchFileException, Paths}
8+
import java.util.concurrent.atomic.AtomicReference
89

910
import scala.build.errors.Severity
1011
import scala.build.internal.WrapperParams
@@ -32,6 +33,9 @@ class ConsoleBloopBuildClient(
3233

3334
private val diagnostics0 = new mutable.ListBuffer[(Either[String, os.Path], bsp4j.Diagnostic)]
3435

36+
private val suspectedClashNames = new AtomicReference(Set.empty[String])
37+
private val matchedClashNames = new AtomicReference(Set.empty[String])
38+
3539
def setGeneratedSources(scope: Scope, newGeneratedSources: Seq[GeneratedSource]) =
3640
generatedSources(scope) = newGeneratedSources
3741
def setProjectParams(newParams: Seq[String]): Unit = {
@@ -41,6 +45,20 @@ class ConsoleBloopBuildClient(
4145
if (keepDiagnostics) Some(diagnostics0.result())
4246
else None
4347

48+
def setSuspectedClashNames(names: Set[String]): Unit =
49+
suspectedClashNames.set(names)
50+
51+
def detectedShadowingCandidates: Set[String] =
52+
matchedClashNames.get()
53+
54+
private def namesMentionedInMessage(message: String, names: Set[String]): Set[String] =
55+
names.filter { name =>
56+
message.contains(name) ||
57+
message.contains(s"$name$$_") ||
58+
message.contains(s"object $name") ||
59+
message.contains(s"`$name`")
60+
}
61+
4462
private def postProcessDiagnostic(
4563
path: os.Path,
4664
diag: bsp4j.Diagnostic,
@@ -81,6 +99,10 @@ class ConsoleBloopBuildClient(
8199
val path = os.Path(Paths.get(new URI(params.getTextDocument.getUri)).toAbsolutePath)
82100
val (updatedPath, updatedDiag) = postProcessDiagnostic(path, diag, diagnosticMappings)
83101
.getOrElse((Right(path), diag))
102+
if updatedDiag.getSeverity == bsp4j.DiagnosticSeverity.ERROR then
103+
val mentioned = namesMentionedInMessage(updatedDiag.getMessage, suspectedClashNames.get())
104+
if mentioned.nonEmpty then
105+
matchedClashNames.updateAndGet(_ ++ mentioned)
84106
if (keepDiagnostics)
85107
diagnostics0 += updatedPath -> updatedDiag
86108
ConsoleBloopBuildClient.printFileDiagnostic(logger, updatedPath, updatedDiag)
@@ -137,6 +159,8 @@ class ConsoleBloopBuildClient(
137159
def clear(): Unit = {
138160
generatedSources.clear()
139161
diagnostics0.clear()
162+
suspectedClashNames.set(Set.empty)
163+
matchedClashNames.set(Set.empty)
140164
printedStart = false
141165
}
142166
}

modules/build/src/main/scala/scala/build/bsp/BspClient.scala

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@ class BspClient(
115115
}
116116

117117
def setProjectParams(newParams: Seq[String]): Unit = {}
118+
def setSuspectedClashNames(names: Set[String]): Unit = {}
119+
def detectedShadowingCandidates: Set[String] = Set.empty
118120
def diagnostics: Option[Seq[(Either[String, os.Path], b.Diagnostic)]] = None
119121
def clear(): Unit = {}
120122

modules/build/src/main/scala/scala/build/bsp/BspImpl.scala

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -720,6 +720,10 @@ object BspImpl {
720720
underlying.setProjectParams(newParams)
721721
def setGeneratedSources(scope: Scope, newGeneratedSources: Seq[GeneratedSource]) =
722722
underlying.setGeneratedSources(scope, newGeneratedSources)
723+
def setSuspectedClashNames(names: Set[String]) =
724+
underlying.setSuspectedClashNames(names)
725+
def detectedShadowingCandidates =
726+
underlying.detectedShadowingCandidates
723727
}
724728

725729
private final case class PreBuildData(

modules/build/src/main/scala/scala/build/internal/ScriptUtils.scala

Lines changed: 38 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,33 +9,55 @@ object ScriptUtils {
99
name: String,
1010
subPath: os.SubPath,
1111
filePath: os.Path,
12-
shadowedDependencyJars: Seq[String] = Nil
12+
shadowedDependencyJars: Seq[String] = Nil,
13+
clashingLocalSources: Seq[os.SubPath] = Nil
1314
)
1415

15-
def findShadowedDependencyPackages(
16+
def findShadowingClashes(
1617
sources: Sources,
1718
classPath: Seq[os.Path],
1819
logger: Logger
1920
): Seq[ScriptDescriptor] = {
2021
val scripts = sources.scriptTopLevelNames.filterNot(s => ignoredPackageRoots(s.name))
2122
if scripts.isEmpty then Nil
2223
else
23-
val packageRoots =
24-
classPath
25-
.filter(_.last.endsWith(".jar"))
26-
.flatMap(path =>
27-
JarUtils.walkClassEntries(path, logger) { (name, _) =>
28-
val slashIdx = name.indexOf('/')
29-
if slashIdx > 0 then Iterator.single(name.take(slashIdx))
30-
else Iterator.empty
31-
}.toSet.map(_ -> path)
32-
)
33-
.groupMap((root, _) => root)((_, path) => path)
34-
.view.mapValues(_.toSet).toMap
24+
val localCandidates = localTopLevelCandidates(sources)
25+
val packageRoots = topLevelPackageRoots(classPath, logger)
3526
scripts.flatMap { script =>
36-
packageRoots.get(script.name).map { jars =>
37-
script.copy(shadowedDependencyJars = jars.toSeq.map(_.last).sorted)
27+
val deps = packageRoots.getOrElse(script.name, Set.empty).toSeq.map(_.last).sorted
28+
val locals = localCandidates.getOrElse(script.name, Nil).filterNot(_ == script.subPath)
29+
Option.when(deps.nonEmpty || locals.nonEmpty) {
30+
script.copy(shadowedDependencyJars = deps, clashingLocalSources = locals)
3831
}
3932
}
4033
}
34+
35+
private def localTopLevelCandidates(sources: Sources): Map[String, Seq[os.SubPath]] =
36+
(sources.paths.map((_, rel) => (baseName(rel.last), os.SubPath(rel.segments.toIndexedSeq))) ++
37+
sources.inMemory.collect {
38+
case Sources.InMemory(Right((subPath, _)), _, _, Some(_)) =>
39+
(baseName(subPath.last), subPath)
40+
})
41+
.groupMap(_._1)(_._2)
42+
43+
private def baseName(fileName: String): String =
44+
val dot = fileName.lastIndexOf('.')
45+
if dot > 0 then fileName.take(dot) else fileName
46+
47+
private def topLevelPackageRoots(
48+
classPath: Seq[os.Path],
49+
logger: Logger
50+
): Map[String, Set[os.Path]] =
51+
classPath
52+
.filter(_.last.endsWith(".jar"))
53+
.flatMap(path =>
54+
JarUtils.walkClassEntries(path, logger) { (name, _) =>
55+
val slashIdx = name.indexOf('/')
56+
if slashIdx > 0 then Iterator.single(name.take(slashIdx))
57+
else Iterator.empty
58+
}.toSet.map(_ -> path)
59+
)
60+
.groupMap((root, _) => root)((_, path) => path)
61+
.view.mapValues(_.toSet).toMap
62+
4163
}

modules/build/src/main/scala/scala/build/internal/util/WarningMessages.scala

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -131,15 +131,22 @@ object WarningMessages {
131131
val mainScriptNameClashesWithAppWrapper =
132132
"Script file named 'main.sc' detected, keep in mind that accessing it from other scripts is impossible due to a clash of `main` symbols"
133133

134-
def scriptNameShadowsDependencyPackage(shadowed: ScriptDescriptor): String =
135-
val name = shadowed.name
136-
val depsClause = shadowed.shadowedDependencyJars match {
137-
case Nil => "from an unknown dependency"
138-
case Seq(j) => s"from dependency '$j'"
139-
case js => s"from dependencies: ${js.map(j => s"'$j'").mkString(", ")}"
134+
def scriptShadowingClashHint(shadowed: ScriptDescriptor): String =
135+
val name = shadowed.name
136+
val depClause = shadowed.shadowedDependencyJars match {
137+
case Nil => None
138+
case Seq(j) => Some(s"the '$name' package from dependency '$j'")
139+
case js =>
140+
Some(s"the '$name' package from dependencies: ${js.map(j => s"'$j'").mkString(", ")}")
140141
}
141-
s"""Script '${shadowed.subPath}' generates a top-level symbol '$name' that shadows the '$name' package $depsClause.
142-
|Imports of '$name.*' from the dependency will not resolve.
142+
val localClause = shadowed.clashingLocalSources match {
143+
case Nil => None
144+
case Seq(p) => Some(s"a local source '$p'")
145+
case ps => Some(s"local sources: ${ps.map(p => s"'$p'").mkString(", ")}")
146+
}
147+
val clashes = Seq(depClause, localClause).flatten.mkString(" and ")
148+
s"""Script '${shadowed.subPath}' generates a top-level symbol '$name' that shadows $clashes.
149+
|This is likely the cause of compilation errors mentioning '$name'.
143150
|Consider renaming the script (e.g. to '${name}1.sc') or moving it into a subdirectory.""".stripMargin
144151

145152
private val deprecationNote =

modules/integration/src/test/scala/scala/cli/integration/RunScriptTestDefinitions.scala

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -270,37 +270,75 @@ trait RunScriptTestDefinitions { this: RunTestDefinitions =>
270270
}
271271
}
272272

273-
test("warn when script name shadows a dependency top-level package") {
273+
test("hint when script name shadows a dependency top-level package and import fails") {
274274
val inputs = TestInputs(
275275
os.rel / "os.sc" ->
276276
s"""//> using dep com.lihaoyi::os-lib:0.11.8
277-
|println("hi")
277+
|import os.Path
278+
|println(Path("/tmp"))
278279
|""".stripMargin
279280
)
280281
inputs.fromRoot { root =>
281282
val res = os.proc(TestUtil.cli, extraOptions, "os.sc")
282283
.call(cwd = root, check = false, mergeErrIntoOut = true)
283284
val output = res.out.trim()
284-
expect(output.contains("shadows the 'os' package"))
285-
expect(res.exitCode == 0)
285+
expect(res.exitCode != 0)
286+
expect(output.contains("shadows the 'os' package from dependencies:"))
287+
expect(output.contains("os-lib"))
288+
expect(output.contains("is likely the cause of compilation errors mentioning 'os'"))
286289
}
287290
}
288291

289-
test("warn with multiple JARs when script name shadows a shared package root") {
292+
test("hint with multiple JARs when script name shadows a shared package root") {
290293
val inputs = TestInputs(
291294
os.rel / "cats.sc" ->
292295
s"""//> using dep org.typelevel::cats-core:2.12.0
293-
|println("hi")
296+
|import cats.Show
297+
|println(Show[Int])
294298
|""".stripMargin
295299
)
296300
inputs.fromRoot { root =>
297301
val res = os.proc(TestUtil.cli, extraOptions, "cats.sc")
298302
.call(cwd = root, check = false, mergeErrIntoOut = true)
299303
val output = res.out.trim()
304+
expect(res.exitCode != 0)
300305
expect(output.contains("shadows the 'cats' package from dependencies:"))
301306
expect(output.contains("cats-core"))
302307
expect(output.contains("cats-kernel"))
303-
expect(res.exitCode == 0)
308+
}
309+
}
310+
311+
test("hint when script name clashes with a sibling .scala file") {
312+
val inputs = TestInputs(
313+
os.rel / "foo.sc" ->
314+
"""println("hi")""",
315+
os.rel / "foo.scala" ->
316+
"""object foo { def hello = "world" }"""
317+
)
318+
inputs.fromRoot { root =>
319+
val res = os.proc(TestUtil.cli, extraOptions, "foo.sc", "foo.scala")
320+
.call(cwd = root, check = false, mergeErrIntoOut = true)
321+
val output = res.out.trim()
322+
expect(res.exitCode != 0)
323+
expect(output.contains("foo.sc"))
324+
expect(output.contains("local source 'foo.scala'") || output.contains("local sources:"))
325+
}
326+
}
327+
328+
test("do not hint when compile fails for an unrelated reason") {
329+
val inputs = TestInputs(
330+
os.rel / "safe.sc" ->
331+
s"""//> using dep com.lihaoyi::os-lib:0.11.8
332+
|val x: Int = "not an int"
333+
|""".stripMargin
334+
)
335+
inputs.fromRoot { root =>
336+
val res = os.proc(TestUtil.cli, extraOptions, "safe.sc")
337+
.call(cwd = root, check = false, mergeErrIntoOut = true)
338+
val output = res.out.trim()
339+
expect(res.exitCode != 0)
340+
expect(!output.contains("shadows"))
341+
expect(!output.contains("is likely the cause"))
304342
}
305343
}
306344

@@ -315,7 +353,8 @@ trait RunScriptTestDefinitions { this: RunTestDefinitions =>
315353
val res = os.proc(TestUtil.cli, extraOptions, "safe.sc")
316354
.call(cwd = root, check = false, mergeErrIntoOut = true)
317355
val output = res.out.trim()
318-
expect(!output.contains("shadows the 'os' package"))
356+
expect(!output.contains("shadows"))
357+
expect(!output.contains("is likely the cause"))
319358
expect(res.exitCode == 0)
320359
}
321360
}

0 commit comments

Comments
 (0)