diff --git a/bleep-core/src/scala/bleep/bootstrap.scala b/bleep-core/src/scala/bleep/bootstrap.scala index e78372966..f9d528ed3 100644 --- a/bleep-core/src/scala/bleep/bootstrap.scala +++ b/bleep-core/src/scala/bleep/bootstrap.scala @@ -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") diff --git a/bleep-core/src/scala/bleep/internal/CheckMisplacedSources.scala b/bleep-core/src/scala/bleep/internal/CheckMisplacedSources.scala new file mode 100644 index 000000000..fe4d69b40 --- /dev/null +++ b/bleep-core/src/scala/bleep/internal/CheckMisplacedSources.scala @@ -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//` (because `sbt-scope` defaults to empty), so files placed under `src/main//` or + * `src/test//` 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() + } +} diff --git a/bleep-tests/src/scala/bleep/CheckMisplacedSourcesTest.scala b/bleep-tests/src/scala/bleep/CheckMisplacedSourcesTest.scala new file mode 100644 index 000000000..51c72d0f6 --- /dev/null +++ b/bleep-tests/src/scala/bleep/CheckMisplacedSourcesTest.scala @@ -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/ 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")) + } +}