Skip to content
Open
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
2 changes: 2 additions & 0 deletions bleep-core/src/scala/bleep/bootstrap.scala
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ object bootstrap {
Some(chosen).filter(_.nonEmpty)
}

internal.CheckMisplacedSources(pre.logger, pre.buildPaths, finalBuild)

val td = System.currentTimeMillis() - t0
pre.logger.info(s"bootstrapped in $td ms")

Expand Down
53 changes: 53 additions & 0 deletions bleep-core/src/scala/bleep/internal/CheckMisplacedSources.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package bleep
package internal

import ryddig.Logger

import java.nio.file.{Files, Path}

/** Warns when a project has source files under sbt/Maven-style `src/{main,test}/{scala,java,kotlin}/` paths that bleep is not picking up.
*
* Bleep's default source layout resolves to `src/<lang>/` (because `sbt-scope` defaults to empty), so files placed under `src/main/<lang>/` or
* `src/test/<lang>/` are silently invisible to the build unless the user explicitly sets `sources:` or `sbt-scope:`. This check surfaces that situation early
* with an actionable message instead of letting the user discover it via a missing-class error at runtime.
*/
object CheckMisplacedSources {

private case class Suspect(scope: String, lang: String, extension: String)

private val suspects: List[Suspect] = for {
scope <- List("main", "test")
(lang, ext) <- List("scala" -> ".scala", "java" -> ".java", "kotlin" -> ".kt")
} yield Suspect(scope, lang, ext)

def apply(logger: Logger, buildPaths: BuildPaths, build: model.Build): Unit =
build.explodedProjects.foreach { case (crossName, project) =>
val projectPaths = buildPaths.project(crossName, project)
val coveredDirs: Set[Path] = projectPaths.sourcesDirs.all.toSet ++ projectPaths.resourcesDirs.all.toSet

// A project that has either an explicit `sources:` list OR an explicit `sbt-scope:` has opted into custom layout — assume the user knows what they're
// doing and skip the heuristic entirely.
val hasUserOverride = project.sources.values.nonEmpty || project.`sbt-scope`.isDefined
if (!hasUserOverride) {
suspects.foreach { suspect =>
val candidate = projectPaths.dir / "src" / suspect.scope / suspect.lang
if (Files.isDirectory(candidate) && !coveredDirs.contains(candidate) && containsSourceFile(candidate, suspect.extension)) {
logger
.withContext("project", crossName.value)
.withContext("path", candidate.toString)
.warn(
s"found ${suspect.lang} sources under sbt/Maven-style 'src/${suspect.scope}/${suspect.lang}/' that bleep is NOT picking up. " +
s"Either move them to 'src/${suspect.lang}/' (bleep's default), set `sources: ./src/${suspect.scope}/${suspect.lang}` " +
s"on this project, or set `sbt-scope: ${suspect.scope}` to opt into the sbt-style layout."
)
}
}
}
}

private def containsSourceFile(dir: Path, extension: String): Boolean = {
val stream = Files.walk(dir)
try stream.anyMatch(p => Files.isRegularFile(p) && p.toString.endsWith(extension))
finally stream.close()
}
}
137 changes: 137 additions & 0 deletions bleep-tests/src/scala/bleep/CheckMisplacedSourcesTest.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package bleep

import bleep.internal.{CheckMisplacedSources, FileUtils}
import org.scalatest.funsuite.AnyFunSuite
import ryddig.LogLevel

import java.nio.file.{Files, Path, Paths}

class CheckMisplacedSourcesTest extends AnyFunSuite {

private val prelude =
"""$schema: https://raw.githubusercontent.com/oyvindberg/bleep/master/schema.json
|$version: dev
|""".stripMargin

private def runCheck(yaml: String, files: List[String]): List[String] = {
val tempDir = Files.createTempDirectory("bleep-misplaced-")
try {
Files.writeString(tempDir.resolve("bleep.yaml"), prelude ++ yaml)
files.foreach { rel =>
val target = tempDir.resolve(rel)
Files.createDirectories(target.getParent)
Files.writeString(target, "")
}

val existing = BuildLoader.find(tempDir).existing.orThrow
val build = model.Build.FileBacked(existing.buildFile.forceGet.orThrow)
val buildPaths = BuildPaths(cwd = tempDir, existing, model.BuildVariant.Normal)

val logger = ThreadSafeStoringLogger().withMinLogLevel(LogLevel.debug)
CheckMisplacedSources(logger, buildPaths, build)
logger.underlying.toList.filter(_.metadata.logLevel == LogLevel.warn).map(_.message.plainText)
} finally FileUtils.deleteDirectory(tempDir)
}

private val kotlinYaml =
"""projects:
| myapp:
| source-layout: kotlin
| kotlin:
| version: 2.1.20
| platform:
| name: jvm
|""".stripMargin

test("kotlin source under src/main/kotlin warns") {
val warns = runCheck(kotlinYaml, List("myapp/src/main/kotlin/test/Foo.kt"))
assert(warns.size === 1, warns)
assert(warns.head.contains("kotlin"))
assert(warns.head.contains("src/main/kotlin"))
}

test("kotlin source at the correct src/kotlin does not warn") {
val warns = runCheck(kotlinYaml, List("myapp/src/kotlin/test/Foo.kt"))
assert(warns === Nil)
}

test("empty src/main/kotlin directory does not warn") {
val tempDir = Files.createTempDirectory("bleep-misplaced-empty-")
try {
Files.createDirectories(tempDir.resolve("myapp/src/main/kotlin/test"))
Files.writeString(tempDir.resolve("bleep.yaml"), prelude ++ kotlinYaml)
val existing = BuildLoader.find(tempDir).existing.orThrow
val build = model.Build.FileBacked(existing.buildFile.forceGet.orThrow)
val buildPaths = BuildPaths(cwd = tempDir, existing, model.BuildVariant.Normal)
val logger = ThreadSafeStoringLogger().withMinLogLevel(LogLevel.debug)
CheckMisplacedSources(logger, buildPaths, build)
val warns = logger.underlying.toList.filter(_.metadata.logLevel == LogLevel.warn)
assert(warns === Nil)
} finally FileUtils.deleteDirectory(tempDir)
}

test("project with explicit sources: override does not warn") {
val yaml =
"""projects:
| myapp:
| source-layout: kotlin
| kotlin:
| version: 2.1.20
| platform:
| name: jvm
| sources: ./src/main/kotlin
|""".stripMargin
val warns = runCheck(yaml, List("myapp/src/main/kotlin/test/Foo.kt"))
assert(warns === Nil)
}

test("project with sbt-scope: main does not warn") {
val yaml =
"""projects:
| myapp:
| source-layout: kotlin
| sbt-scope: main
| kotlin:
| version: 2.1.20
| platform:
| name: jvm
|""".stripMargin
val warns = runCheck(yaml, List("myapp/src/main/kotlin/test/Foo.kt"))
assert(warns === Nil)
}

test("java source under src/main/java warns") {
val yaml =
"""projects:
| myapp:
| java: {}
| platform:
| name: jvm
|""".stripMargin
val warns = runCheck(yaml, List("myapp/src/main/java/test/Foo.java"))
assert(warns.size === 1, warns)
assert(warns.head.contains("java"))
assert(warns.head.contains("src/main/java"))
}

test("scala source under src/main/scala warns") {
val yaml =
"""projects:
| myapp:
| scala:
| version: 3.3.3
| platform:
| name: jvm
|""".stripMargin
val warns = runCheck(yaml, List("myapp/src/main/scala/test/Foo.scala"))
assert(warns.size === 1, warns)
assert(warns.head.contains("scala"))
assert(warns.head.contains("src/main/scala"))
}

test("src/test/<lang> is also caught") {
val warns = runCheck(kotlinYaml, List("myapp/src/test/kotlin/test/Foo.kt"))
assert(warns.size === 1, warns)
assert(warns.head.contains("src/test/kotlin"))
}
}
Loading